Update Todos
Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py
Build /rounds list page + component
Build /rounds/new creation flow (course search/create, tee, date, holes)
Build /rounds/[id] detail page (participants, hole-by-hole stats, complete)
Link new feature from dashboard
Scratch-verify backend additions + typecheck production build
Deploy live after user confirmation
Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status
Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]).
What's new, in summary:
Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched.
Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card.
One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
This commit is contained in:
parent
3270d044de
commit
c15c68efca
9 changed files with 2002 additions and 5 deletions
|
|
@ -350,7 +350,12 @@
|
||||||
"Bash(sudo -n apt-get install -y poppler-utils)",
|
"Bash(sudo -n apt-get install -y poppler-utils)",
|
||||||
"Bash(python3 -m pytest test_handicap_engine.py -q)",
|
"Bash(python3 -m pytest test_handicap_engine.py -q)",
|
||||||
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/rounds.py'\\).read\\(\\)\\)\")",
|
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/rounds.py'\\).read\\(\\)\\)\")",
|
||||||
"Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)"
|
"Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)",
|
||||||
|
"Bash(docker run -d --name teecup-minio-scratch --network teeoff_default \\\\ *)",
|
||||||
|
"Bash(docker run -d --name teecup_api_scratch --network teeoff_default \\\\ *)",
|
||||||
|
"Bash(docker rm -f teecup_api_scratch >/dev/null 2>&1 *)",
|
||||||
|
"Bash(python3 test_rounds_frontend_endpoints.py)",
|
||||||
|
"Bash(TEECUP_API_ORIGIN=http://localhost:8000 npx next build)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,17 @@ class PersonalCourseOut(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class TeeOption(BaseModel):
|
||||||
|
name: str
|
||||||
|
genders: list[Literal["m", "f"]]
|
||||||
|
|
||||||
|
|
||||||
|
class PersonalCourseDetail(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
tees: list[TeeOption]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/personal-courses", response_model=list[PersonalCourseOut])
|
@router.get("/personal-courses", response_model=list[PersonalCourseOut])
|
||||||
async def search_personal_courses(
|
async def search_personal_courses(
|
||||||
q: str = "",
|
q: str = "",
|
||||||
|
|
@ -93,6 +104,35 @@ async def search_personal_courses(
|
||||||
return [PersonalCourseOut(**dict(r)) for r in rows]
|
return [PersonalCourseOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/personal-courses/{personal_course_id}", response_model=PersonalCourseDetail)
|
||||||
|
async def get_personal_course(
|
||||||
|
personal_course_id: str,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> PersonalCourseDetail:
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
course_row = await conn.fetchrow("SELECT id::text AS id, name FROM personal_course WHERE id = $1", personal_course_id)
|
||||||
|
if course_row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
|
||||||
|
tee_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT t.name AS tee_name, r.gender
|
||||||
|
FROM personal_course_tee t
|
||||||
|
JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
|
||||||
|
WHERE t.personal_course_id = $1
|
||||||
|
ORDER BY t.name
|
||||||
|
""",
|
||||||
|
personal_course_id,
|
||||||
|
)
|
||||||
|
tees: dict[str, list[str]] = {}
|
||||||
|
for r in tee_rows:
|
||||||
|
tees.setdefault(r["tee_name"], []).append(r["gender"])
|
||||||
|
return PersonalCourseDetail(
|
||||||
|
id=course_row["id"],
|
||||||
|
name=course_row["name"],
|
||||||
|
tees=[TeeOption(name=name, genders=genders) for name, genders in tees.items()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/personal-courses", response_model=PersonalCourseOut, status_code=201)
|
@router.post("/personal-courses", response_model=PersonalCourseOut, status_code=201)
|
||||||
async def create_personal_course(
|
async def create_personal_course(
|
||||||
body: PersonalCourseCreate,
|
body: PersonalCourseCreate,
|
||||||
|
|
@ -140,6 +180,83 @@ async def create_personal_course(
|
||||||
return PersonalCourseOut(**dict(course_row))
|
return PersonalCourseOut(**dict(course_row))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Offisiell bane fra teeoff (ADR-019/ADR-033 Beslutning C) -- IKKE org-scopet,
|
||||||
|
# ulikt courses.py sine tilsvarende endepunkter (frittstående runder har
|
||||||
|
# ingen organisasjon). Rent lese-søk, ingen import/persistering av banen.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class OfficialFacility(BaseModel):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
city: str | None
|
||||||
|
county: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class OfficialCourseOption(BaseModel):
|
||||||
|
teeoff_course_id: int
|
||||||
|
name: str
|
||||||
|
is_main_course: bool
|
||||||
|
tees: list[TeeOption]
|
||||||
|
|
||||||
|
|
||||||
|
class OfficialFacilityDetail(BaseModel):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
courses: list[OfficialCourseOption]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rounds/official-search", response_model=list[OfficialFacility])
|
||||||
|
async def search_official_courses_for_round(
|
||||||
|
q: str = "",
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[OfficialFacility]:
|
||||||
|
try:
|
||||||
|
facilities = await teeoff_client.search_facilities(q)
|
||||||
|
except teeoff_client.TeeoffUnavailableError:
|
||||||
|
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra teeoff akkurat nå.")
|
||||||
|
return [
|
||||||
|
OfficialFacility(slug=f["slug"], name=f["name"], city=f.get("city"), county=f.get("county"))
|
||||||
|
for f in facilities
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rounds/official-search/{slug}", response_model=OfficialFacilityDetail)
|
||||||
|
async def get_official_facility_for_round(
|
||||||
|
slug: str,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> OfficialFacilityDetail:
|
||||||
|
try:
|
||||||
|
facility = await teeoff_client.get_facility(slug)
|
||||||
|
except teeoff_client.TeeoffNotFoundError:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.")
|
||||||
|
except teeoff_client.TeeoffUnavailableError:
|
||||||
|
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.")
|
||||||
|
|
||||||
|
courses: list[OfficialCourseOption] = []
|
||||||
|
for c in facility.get("courses", []):
|
||||||
|
if len(c.get("holes") or []) != 18:
|
||||||
|
continue
|
||||||
|
tees: list[TeeOption] = []
|
||||||
|
for t in c.get("tees") or []:
|
||||||
|
genders: list[str] = []
|
||||||
|
if t.get("cr_men") is not None and t.get("slope_men") is not None:
|
||||||
|
genders.append("m")
|
||||||
|
if t.get("cr_women") is not None and t.get("slope_women") is not None:
|
||||||
|
genders.append("f")
|
||||||
|
if genders:
|
||||||
|
tees.append(TeeOption(name=t.get("name") or "Tee", genders=genders))
|
||||||
|
courses.append(
|
||||||
|
OfficialCourseOption(
|
||||||
|
teeoff_course_id=c["id"],
|
||||||
|
name=c["name"],
|
||||||
|
is_main_course=bool(c.get("is_main_course")),
|
||||||
|
tees=tees,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Banedata-oppslag (delt mellom opprett-runde og legg-til-deltaker)
|
# Banedata-oppslag (delt mellom opprett-runde og legg-til-deltaker)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -512,6 +629,51 @@ async def remove_guest_participant(
|
||||||
# Hull-for-hull-registrering
|
# Hull-for-hull-registrering
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RoundHoleOut(BaseModel):
|
||||||
|
hole_number: int
|
||||||
|
par: int
|
||||||
|
stroke_index: int
|
||||||
|
played: bool
|
||||||
|
score: int | None
|
||||||
|
putts: int | None
|
||||||
|
club_off_tee: str | None
|
||||||
|
tee_shot_result: str | None
|
||||||
|
approach_result: str | None
|
||||||
|
chip_count: int | None
|
||||||
|
bunker_shot_count: int | None
|
||||||
|
penalty_strokes: int | None
|
||||||
|
first_putt_distance_m: float | None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/rounds/{round_id}/participants/{participant_id}/holes",
|
||||||
|
response_model=list[RoundHoleOut],
|
||||||
|
)
|
||||||
|
async def list_holes(
|
||||||
|
round_id: str,
|
||||||
|
participant_id: str,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[RoundHoleOut]:
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||||
|
owner_check = await conn.fetchval(
|
||||||
|
"SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2",
|
||||||
|
participant_id, round_id,
|
||||||
|
)
|
||||||
|
if owner_check is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee,
|
||||||
|
tee_shot_result, approach_result, chip_count, bunker_shot_count,
|
||||||
|
penalty_strokes, first_putt_distance_m::float AS first_putt_distance_m
|
||||||
|
FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
|
||||||
|
""",
|
||||||
|
participant_id,
|
||||||
|
)
|
||||||
|
return [RoundHoleOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
class HoleUpdate(BaseModel):
|
class HoleUpdate(BaseModel):
|
||||||
played: bool = True
|
played: bool = True
|
||||||
score: int | None = Field(default=None, ge=1, le=20)
|
score: int | None = Field(default=None, ge=1, le=20)
|
||||||
|
|
@ -525,14 +687,17 @@ class HoleUpdate(BaseModel):
|
||||||
first_putt_distance_m: float | None = Field(default=None, ge=0)
|
first_putt_distance_m: float | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}")
|
@router.patch(
|
||||||
|
"/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}",
|
||||||
|
response_model=RoundHoleOut,
|
||||||
|
)
|
||||||
async def update_hole(
|
async def update_hole(
|
||||||
round_id: str,
|
round_id: str,
|
||||||
participant_id: str,
|
participant_id: str,
|
||||||
hole_number: int,
|
hole_number: int,
|
||||||
body: HoleUpdate,
|
body: HoleUpdate,
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> RoundHoleOut:
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||||
owner_check = await conn.fetchval(
|
owner_check = await conn.fetchval(
|
||||||
|
|
@ -550,7 +715,9 @@ async def update_hole(
|
||||||
tee_shot_result = $7, approach_result = $8, chip_count = $9,
|
tee_shot_result = $7, approach_result = $8, chip_count = $9,
|
||||||
bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_m = $12
|
bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_m = $12
|
||||||
WHERE round_participant_id = $1 AND hole_number = $2
|
WHERE round_participant_id = $1 AND hole_number = $2
|
||||||
RETURNING id
|
RETURNING hole_number, par, stroke_index, played, score, putts, club_off_tee,
|
||||||
|
tee_shot_result, approach_result, chip_count, bunker_shot_count,
|
||||||
|
penalty_strokes, first_putt_distance_m::float AS first_putt_distance_m
|
||||||
""",
|
""",
|
||||||
participant_id, hole_number,
|
participant_id, hole_number,
|
||||||
body.played, body.score, body.putts, body.club_off_tee,
|
body.played, body.score, body.putts, body.club_off_tee,
|
||||||
|
|
@ -559,7 +726,7 @@ async def update_hole(
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
|
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
|
||||||
return {"ok": True}
|
return RoundHoleOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
6
frontend/app/rounds/[id]/page.tsx
Normal file
6
frontend/app/rounds/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { RoundDetail } from "@/components/round-detail"
|
||||||
|
|
||||||
|
export default async function RoundDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
return <RoundDetail roundId={id} />
|
||||||
|
}
|
||||||
5
frontend/app/rounds/new/page.tsx
Normal file
5
frontend/app/rounds/new/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { NewRound } from "@/components/round-new"
|
||||||
|
|
||||||
|
export default function NewRoundPage() {
|
||||||
|
return <NewRound />
|
||||||
|
}
|
||||||
5
frontend/app/rounds/page.tsx
Normal file
5
frontend/app/rounds/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { PersonalRounds } from "@/components/personal-rounds"
|
||||||
|
|
||||||
|
export default function RoundsPage() {
|
||||||
|
return <PersonalRounds />
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
Flag,
|
||||||
LogOut,
|
LogOut,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -231,6 +232,13 @@ export function Dashboard() {
|
||||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
||||||
<Wordmark compact />
|
<Wordmark compact />
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<Link
|
||||||
|
href="/rounds"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Flag aria-hidden="true" className="size-4" />
|
||||||
|
Egne runder
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/account"
|
href="/account"
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
|
@ -258,6 +266,8 @@ export function Dashboard() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
|
<PersonalRoundsEntry />
|
||||||
|
|
||||||
{hasMyTournaments && <MyToursSection tournaments={me.my_tournaments} />}
|
{hasMyTournaments && <MyToursSection tournaments={me.my_tournaments} />}
|
||||||
|
|
||||||
{hasOrg && activeOrg ? (
|
{hasOrg && activeOrg ? (
|
||||||
|
|
@ -279,6 +289,35 @@ export function Dashboard() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- "Egne runder" (ADR-033) -------------------------------------------------
|
||||||
|
// Frittstående rundeføring med detaljert statistikk -- eid direkte av
|
||||||
|
// brukeren (app_user), uavhengig av organisasjon/turnering. Bevisst atskilt
|
||||||
|
// navn fra "Mine runder" under (turnering-deltakelse) for å unngå
|
||||||
|
// forveksling mellom de to konseptene.
|
||||||
|
|
||||||
|
function PersonalRoundsEntry() {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/rounds"
|
||||||
|
className="group flex items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 sm:p-5"
|
||||||
|
>
|
||||||
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
|
||||||
|
<Flag aria-hidden="true" className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Egne runder</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Registrer en runde på egen hånd, med detaljert statistikk -- uavhengig av turnering.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- "Mine runder" (ADR-031) -----------------------------------------------
|
// --- "Mine runder" (ADR-031) -----------------------------------------------
|
||||||
// Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap.
|
// Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap.
|
||||||
// Kortets hoveddel lenker til den offentlige turnering-siden. Fra
|
// Kortets hoveddel lenker til den offentlige turnering-siden. Fra
|
||||||
|
|
|
||||||
162
frontend/components/personal-rounds.tsx
Normal file
162
frontend/components/personal-rounds.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Frittstående rundeføring (ADR-033) -- runder eid direkte av en BRUKER
|
||||||
|
// (app_user), ikke en organisasjon. Bevisst adskilt navn fra dashbordets
|
||||||
|
// "Mine runder" (turnering-deltakelse, se MyToursSection i dashboard.tsx)
|
||||||
|
// for å unngå forveksling -- denne funksjonen kalles "Egne runder" overalt
|
||||||
|
// i UI-et.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { ArrowLeft, Calendar, ChevronRight, Flag, Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Wordmark } from "@/components/wordmark"
|
||||||
|
|
||||||
|
type ApiRoundParticipant = {
|
||||||
|
id: string
|
||||||
|
is_owner: boolean
|
||||||
|
guest_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiRound = {
|
||||||
|
id: string
|
||||||
|
course_name_snapshot: string
|
||||||
|
tee_name_snapshot: string
|
||||||
|
played_at: string
|
||||||
|
holes_planned: number
|
||||||
|
completed_at: string | null
|
||||||
|
participants: ApiRoundParticipant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersonalRounds() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [rounds, setRounds] = useState<ApiRound[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/rounds", { credentials: "include" })
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.replace("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
||||||
|
const data: ApiRound[] = await res.json()
|
||||||
|
if (!cancelled) setRounds(data)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
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-3xl items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||||
|
Dashbord
|
||||||
|
</Link>
|
||||||
|
<Wordmark compact />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
||||||
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-extrabold tracking-tight text-foreground">Egne runder</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Registrer en runde på egen hånd, med eller uten turnering, og få detaljert statistikk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/rounds/new">
|
||||||
|
<Button className="h-12 rounded-2xl text-base font-bold shadow-sm">
|
||||||
|
<Plus aria-hidden="true" className="size-5" />
|
||||||
|
Ny runde
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rounds === null ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : rounds.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
|
<Flag aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-base font-bold text-foreground text-balance">Ingen runder registrert ennå</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Trykk på «Ny runde» for å registrere den første.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{rounds.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<Link
|
||||||
|
href={`/rounds/${r.id}`}
|
||||||
|
className="group flex items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 sm:p-5"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<h3 className="truncate text-base font-bold text-foreground">{r.course_name_snapshot}</h3>
|
||||||
|
{r.completed_at ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary/15 px-2.5 py-0.5 text-xs font-bold text-primary">
|
||||||
|
Fullført
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-xs font-bold text-muted-foreground">
|
||||||
|
Pågår
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{r.tee_name_snapshot} · {r.holes_planned} hull · {formatDate(r.played_at)}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Calendar aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{r.participants.length} {r.participants.length === 1 ? "spiller" : "spillere"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return iso
|
||||||
|
return new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" }).format(date)
|
||||||
|
}
|
||||||
774
frontend/components/round-detail.tsx
Normal file
774
frontend/components/round-detail.tsx
Normal file
|
|
@ -0,0 +1,774 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Hull-for-hull-registrering for en frittstående runde (ADR-033). Én
|
||||||
|
// deltaker om gangen velges via fanene øverst -- hver har sitt eget sett
|
||||||
|
// med 18 round_hole-rader (opprettet ved runde-/deltaker-opprettelse).
|
||||||
|
// PATCH-endepunktet erstatter ALLE felt på hullet ved hver kall (ikke et
|
||||||
|
// ekte delvis-PATCH) -- derfor sendes alltid hele det gjeldende hullet,
|
||||||
|
// kun ETT felt endret, aldri bare det isolerte feltet som ble trykket på.
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { ArrowLeft, Check, ChevronLeft, ChevronRight, Plus, Trophy, UserPlus, X } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Wordmark } from "@/components/wordmark"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type Gender = "m" | "f" | "x"
|
||||||
|
|
||||||
|
type ApiParticipant = {
|
||||||
|
id: string
|
||||||
|
user_id: string | null
|
||||||
|
guest_name: string | null
|
||||||
|
is_owner: boolean
|
||||||
|
gender: Gender
|
||||||
|
handicap_index_snapshot: number | null
|
||||||
|
course_handicap_snapshot: number | null
|
||||||
|
counts_for_handicap: boolean
|
||||||
|
score_differential: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiRound = {
|
||||||
|
id: string
|
||||||
|
course_source: string
|
||||||
|
course_name_snapshot: string
|
||||||
|
tee_name_snapshot: string
|
||||||
|
played_at: string
|
||||||
|
start_hole: number
|
||||||
|
holes_planned: number
|
||||||
|
completed_at: string | null
|
||||||
|
participants: ApiParticipant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeeShotResult = "fairway" | "left" | "right"
|
||||||
|
type ApproachResult = "hit" | "long" | "short" | "left" | "right"
|
||||||
|
|
||||||
|
type ApiHole = {
|
||||||
|
hole_number: number
|
||||||
|
par: number
|
||||||
|
stroke_index: number
|
||||||
|
played: boolean
|
||||||
|
score: number | null
|
||||||
|
putts: number | null
|
||||||
|
club_off_tee: string | null
|
||||||
|
tee_shot_result: TeeShotResult | null
|
||||||
|
approach_result: ApproachResult | null
|
||||||
|
chip_count: number | null
|
||||||
|
bunker_shot_count: number | null
|
||||||
|
penalty_strokes: number | null
|
||||||
|
first_putt_distance_m: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function holeOrder(startHole: number): number[] {
|
||||||
|
return Array.from({ length: 18 }, (_, i) => ((startHole - 1 + i) % 18) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function participantLabel(p: ApiParticipant): string {
|
||||||
|
return p.is_owner ? "Deg" : p.guest_name ?? "Gjest"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [round, setRound] = useState<ApiRound | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [activeParticipantId, setActiveParticipantId] = useState<string | null>(null)
|
||||||
|
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
|
||||||
|
const [currentHole, setCurrentHole] = useState<number>(1)
|
||||||
|
const [showAddGuest, setShowAddGuest] = useState(false)
|
||||||
|
const [completing, setCompleting] = useState(false)
|
||||||
|
|
||||||
|
const loadRound = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.replace("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.status === 403 || res.status === 404) {
|
||||||
|
setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`round: ${res.status}`)
|
||||||
|
const data: ApiRound = await res.json()
|
||||||
|
setRound(data)
|
||||||
|
setActiveParticipantId((prev) => prev ?? data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
|
||||||
|
setCurrentHole((prev) => prev || data.start_hole)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å hente runden. Prøv igjen om litt.")
|
||||||
|
}
|
||||||
|
}, [roundId, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRound()
|
||||||
|
}, [loadRound])
|
||||||
|
|
||||||
|
const loadHoles = useCallback(
|
||||||
|
async (participantId: string) => {
|
||||||
|
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
|
||||||
|
if (!res.ok) return
|
||||||
|
const data: ApiHole[] = await res.json()
|
||||||
|
setHolesByParticipant((prev) => ({ ...prev, [participantId]: data }))
|
||||||
|
},
|
||||||
|
[roundId],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeParticipantId && !holesByParticipant[activeParticipantId]) {
|
||||||
|
void loadHoles(activeParticipantId)
|
||||||
|
}
|
||||||
|
}, [activeParticipantId, holesByParticipant, loadHoles])
|
||||||
|
|
||||||
|
async function handleUpdateHole(patch: Partial<ApiHole>) {
|
||||||
|
if (!activeParticipantId || !round) return
|
||||||
|
const holes = holesByParticipant[activeParticipantId]
|
||||||
|
const existing = holes?.find((h) => h.hole_number === currentHole)
|
||||||
|
if (!existing) return
|
||||||
|
const merged: ApiHole = { ...existing, ...patch }
|
||||||
|
const res = await fetch(`/rounds/${roundId}/participants/${activeParticipantId}/holes/${currentHole}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
played: merged.played,
|
||||||
|
score: merged.score,
|
||||||
|
putts: merged.putts,
|
||||||
|
club_off_tee: merged.club_off_tee,
|
||||||
|
tee_shot_result: merged.tee_shot_result,
|
||||||
|
approach_result: merged.approach_result,
|
||||||
|
chip_count: merged.chip_count,
|
||||||
|
bunker_shot_count: merged.bunker_shot_count,
|
||||||
|
penalty_strokes: merged.penalty_strokes,
|
||||||
|
first_putt_distance_m: merged.first_putt_distance_m,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) return
|
||||||
|
const updated: ApiHole = await res.json()
|
||||||
|
setHolesByParticipant((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[activeParticipantId]: (prev[activeParticipantId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddGuest(name: string, gender: Gender, hcp: number | null) {
|
||||||
|
const res = await fetch(`/rounds/${roundId}/participants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ guest_name: name, gender, handicap_index: hcp }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowAddGuest(false)
|
||||||
|
await loadRound()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveGuest(participantId: string) {
|
||||||
|
if (!confirm("Fjerne denne spilleren fra runden?")) return
|
||||||
|
const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, { method: "DELETE", credentials: "include" })
|
||||||
|
if (!res.ok) return
|
||||||
|
if (activeParticipantId === participantId) setActiveParticipantId(round?.participants.find((p) => p.is_owner)?.id ?? null)
|
||||||
|
await loadRound()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComplete() {
|
||||||
|
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
|
||||||
|
setCompleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" })
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
setRound(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å fullføre runden. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setCompleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||||||
|
<p className="text-sm font-medium text-destructive">{error}</p>
|
||||||
|
<Link href="/rounds" className="text-sm font-semibold text-primary underline underline-offset-2">
|
||||||
|
Tilbake til egne runder
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
|
||||||
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeParticipant = round.participants.find((p) => p.id === activeParticipantId) ?? null
|
||||||
|
const holes = activeParticipantId ? holesByParticipant[activeParticipantId] : undefined
|
||||||
|
const currentHoleData = holes?.find((h) => h.hole_number === currentHole) ?? null
|
||||||
|
const isCompleted = round.completed_at !== null
|
||||||
|
const order = holeOrder(round.start_hole)
|
||||||
|
|
||||||
|
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-2xl items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href="/rounds"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||||
|
Egne runder
|
||||||
|
</Link>
|
||||||
|
<Wordmark compact />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
|
||||||
|
<div className="mb-5 flex flex-col gap-1">
|
||||||
|
<h1 className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||||||
|
{round.course_name_snapshot}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{round.tee_name_snapshot} · {round.holes_planned} hull · {formatDate(round.played_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCompleted && <CompletedSummary round={round} />}
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
{round.participants.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveParticipantId(p.id)}
|
||||||
|
aria-pressed={activeParticipantId === p.id}
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 items-center gap-2 rounded-xl border px-3 text-sm font-bold transition-colors",
|
||||||
|
activeParticipantId === p.id
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{participantLabel(p)}
|
||||||
|
{!p.is_owner && !isCompleted && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
void handleRemoveGuest(p.id)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.stopPropagation()
|
||||||
|
void handleRemoveGuest(p.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`Fjern ${p.guest_name}`}
|
||||||
|
className="-mr-1 flex size-6 items-center justify-center rounded-full hover:bg-black/10"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!isCompleted && !showAddGuest && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowAddGuest(true)}
|
||||||
|
className="size-11 shrink-0 rounded-xl"
|
||||||
|
aria-label="Legg til spiller"
|
||||||
|
>
|
||||||
|
<UserPlus aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddGuest && <AddGuestForm onCancel={() => setShowAddGuest(false)} onAdd={handleAddGuest} />}
|
||||||
|
|
||||||
|
{activeParticipant && (
|
||||||
|
<>
|
||||||
|
<HoleStrip order={order} holes={holes} currentHole={currentHole} onSelect={setCurrentHole} />
|
||||||
|
|
||||||
|
{currentHoleData ? (
|
||||||
|
<HolePanel
|
||||||
|
hole={currentHoleData}
|
||||||
|
readOnly={isCompleted}
|
||||||
|
onChange={handleUpdateHole}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentHole(order[(order.indexOf(currentHole) - 1 + 18) % 18])}
|
||||||
|
className="h-12 flex-1 rounded-xl text-sm font-bold"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" className="size-4" />
|
||||||
|
Forrige
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentHole(order[(order.indexOf(currentHole) + 1) % 18])}
|
||||||
|
className="h-12 flex-1 rounded-xl text-sm font-bold"
|
||||||
|
>
|
||||||
|
Neste
|
||||||
|
<ChevronRight aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCompleted && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={completing}
|
||||||
|
onClick={handleComplete}
|
||||||
|
className="mt-8 h-14 w-full rounded-2xl text-base font-bold shadow-sm"
|
||||||
|
>
|
||||||
|
<Check aria-hidden="true" className="size-5" />
|
||||||
|
{completing ? "Fullfører…" : "Fullfør runde"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fullført-sammendrag -----------------------------------------------------
|
||||||
|
|
||||||
|
function CompletedSummary({ round }: { round: ApiRound }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-primary/30 bg-primary/5 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trophy aria-hidden="true" className="size-5 text-primary" />
|
||||||
|
<span className="text-base font-bold text-foreground">Runde fullført</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{round.participants.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="font-semibold text-foreground">{participantLabel(p)}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{p.counts_for_handicap && p.score_differential !== null
|
||||||
|
? `Differensial: ${p.score_differential.toFixed(1)}`
|
||||||
|
: "Telte ikke mot HCP"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Legg til gjest -----------------------------------------------------------
|
||||||
|
|
||||||
|
function AddGuestForm({
|
||||||
|
onCancel,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
onCancel: () => void
|
||||||
|
onAdd: (name: string, gender: Gender, hcp: number | null) => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [gender, setGender] = useState<Gender>("m")
|
||||||
|
const [hcp, setHcp] = useState("")
|
||||||
|
const valid = name.trim().length > 0
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!valid) return
|
||||||
|
onAdd(name.trim(), gender, hcp.trim() ? Number(hcp) : null)
|
||||||
|
setName("")
|
||||||
|
setHcp("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mb-4 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-bold text-foreground">Legg til spiller</span>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={onCancel} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="guest-name" className="text-sm font-semibold">
|
||||||
|
Navn
|
||||||
|
</Label>
|
||||||
|
<Input id="guest-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} className="h-11 rounded-xl text-base" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="guest-gender" className="text-sm font-semibold">
|
||||||
|
Kjønn
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="guest-gender"
|
||||||
|
value={gender}
|
||||||
|
onChange={(e) => setGender(e.target.value as Gender)}
|
||||||
|
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-medium text-foreground"
|
||||||
|
>
|
||||||
|
<option value="f">Dame</option>
|
||||||
|
<option value="m">Herre</option>
|
||||||
|
<option value="x">Annet</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="guest-hcp" className="text-sm font-semibold">
|
||||||
|
HCP (valgfritt)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="guest-hcp"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={hcp}
|
||||||
|
onChange={(e) => setHcp(e.target.value)}
|
||||||
|
className="h-11 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={!valid} className="h-11 rounded-xl text-sm font-bold">
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Legg til
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hull-navigasjon ----------------------------------------------------------
|
||||||
|
|
||||||
|
function HoleStrip({
|
||||||
|
order,
|
||||||
|
holes,
|
||||||
|
currentHole,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
order: number[]
|
||||||
|
holes: ApiHole[] | undefined
|
||||||
|
currentHole: number
|
||||||
|
onSelect: (n: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex gap-1.5 overflow-x-auto pb-1">
|
||||||
|
{order.map((n) => {
|
||||||
|
const h = holes?.find((x) => x.hole_number === n)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(n)}
|
||||||
|
aria-pressed={currentHole === n}
|
||||||
|
className={cn(
|
||||||
|
"flex size-11 shrink-0 flex-col items-center justify-center rounded-xl border text-sm font-bold tabular-nums transition-colors",
|
||||||
|
currentHole === n
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: h?.played
|
||||||
|
? "border-primary/40 bg-primary/10 text-foreground"
|
||||||
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hull-panel -----------------------------------------------------------
|
||||||
|
|
||||||
|
function HolePanel({
|
||||||
|
hole,
|
||||||
|
readOnly,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
hole: ApiHole
|
||||||
|
readOnly: boolean
|
||||||
|
onChange: (patch: Partial<ApiHole>) => void
|
||||||
|
}) {
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
const gir =
|
||||||
|
hole.approach_result === "hit" && hole.score !== null && hole.putts !== null
|
||||||
|
? hole.score - hole.putts <= hole.par - 2
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {hole.hole_number}</h2>
|
||||||
|
<span className="text-lg font-bold text-muted-foreground">
|
||||||
|
· Par {hole.par} · Idx {hole.stroke_index}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{gir !== null && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold",
|
||||||
|
gir ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{gir ? "GIR ✓" : "Ikke GIR"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2.5 text-sm font-semibold text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hole.played}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(e) => onChange({ played: e.target.checked })}
|
||||||
|
className="size-5 rounded border-border"
|
||||||
|
/>
|
||||||
|
Hullet er spilt
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Slag</span>
|
||||||
|
<NumberPicker value={hole.score} min={1} max={20} disabled={readOnly} onSelect={(n) => onChange({ score: n, played: true })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Putter</span>
|
||||||
|
<NumberPicker value={hole.putts} min={0} max={10} disabled={readOnly} onSelect={(n) => onChange({ putts: n })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDetails((v) => !v)}
|
||||||
|
className="self-start text-sm font-semibold text-primary"
|
||||||
|
>
|
||||||
|
{showDetails ? "Skjul detaljer" : "Flere detaljer (kølle, retning, chip, bunker …)"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="club-off-tee" className="text-sm font-semibold">
|
||||||
|
Kølle brukt ved utslaget
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="club-off-tee"
|
||||||
|
disabled={readOnly}
|
||||||
|
value={hole.club_off_tee ?? ""}
|
||||||
|
onChange={(e) => onChange({ club_off_tee: e.target.value || null })}
|
||||||
|
placeholder="F.eks. Driver"
|
||||||
|
className="h-11 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hole.par >= 4 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Utslag</span>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(["left", "fairway", "right"] as const).map((v) => (
|
||||||
|
<ChoiceButton
|
||||||
|
key={v}
|
||||||
|
active={hole.tee_shot_result === v}
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => onChange({ tee_shot_result: v })}
|
||||||
|
>
|
||||||
|
{v === "fairway" ? "Fairway" : v === "left" ? "Venstre" : "Høyre"}
|
||||||
|
</ChoiceButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Innspill</span>
|
||||||
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||||
|
{(["left", "short", "hit", "long", "right"] as const).map((v) => (
|
||||||
|
<ChoiceButton
|
||||||
|
key={v}
|
||||||
|
active={hole.approach_result === v}
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => onChange({ approach_result: v })}
|
||||||
|
>
|
||||||
|
{v === "hit" ? "Traff" : v === "long" ? "Langt" : v === "short" ? "Kort" : v === "left" ? "Venstre" : "Høyre"}
|
||||||
|
</ChoiceButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<SmallStepper label="Chip" value={hole.chip_count} disabled={readOnly} onChange={(n) => onChange({ chip_count: n })} />
|
||||||
|
<SmallStepper label="Bunker" value={hole.bunker_shot_count} disabled={readOnly} onChange={(n) => onChange({ bunker_shot_count: n })} />
|
||||||
|
<SmallStepper label="Straffeslag" value={hole.penalty_strokes} disabled={readOnly} onChange={(n) => onChange({ penalty_strokes: n })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="first-putt" className="text-sm font-semibold">
|
||||||
|
Avstand første putt (m)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="first-putt"
|
||||||
|
inputMode="decimal"
|
||||||
|
disabled={readOnly}
|
||||||
|
value={hole.first_putt_distance_m ?? ""}
|
||||||
|
onChange={(e) => onChange({ first_putt_distance_m: e.target.value === "" ? null : Number(e.target.value) })}
|
||||||
|
className="h-11 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoiceButton({
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
disabled: boolean
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 items-center justify-center rounded-xl border text-sm font-bold transition-colors disabled:opacity-60",
|
||||||
|
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmallStepper({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number | null
|
||||||
|
disabled: boolean
|
||||||
|
onChange: (n: number) => void
|
||||||
|
}) {
|
||||||
|
const current = value ?? 0
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled || current <= 0}
|
||||||
|
onClick={() => onChange(Math.max(0, current - 1))}
|
||||||
|
className="size-9 shrink-0 rounded-lg"
|
||||||
|
aria-label={`Reduser ${label}`}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</Button>
|
||||||
|
<span className="w-6 text-center text-base font-bold tabular-nums text-foreground">{current}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(current + 1)}
|
||||||
|
className="size-9 shrink-0 rounded-lg"
|
||||||
|
aria-label={`Øk ${label}`}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tallvelger, samme mønster som StrokePicker i session-scorecard.tsx: rask
|
||||||
|
// direkte-trykk for de vanligste verdiene, med en "flere"-utvidelse for
|
||||||
|
// resten -- ikke en +/- stepper (for mange klikk for typiske slagtall).
|
||||||
|
function NumberPicker({
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
disabled,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
value: number | null
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
disabled: boolean
|
||||||
|
onSelect: (n: number) => void
|
||||||
|
}) {
|
||||||
|
const splitAt = Math.min(min + 8, max)
|
||||||
|
const [showHigh, setShowHigh] = useState(value !== null && value > splitAt)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowHigh(value !== null && value > splitAt)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const low = Array.from({ length: splitAt - min + 1 }, (_, i) => min + i)
|
||||||
|
const high = Array.from({ length: max - splitAt }, (_, i) => splitAt + 1 + i)
|
||||||
|
const numbers = showHigh ? high : low
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{numbers.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onSelect(n)}
|
||||||
|
aria-pressed={value === n}
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 items-center justify-center rounded-xl border text-base font-bold tabular-nums transition-colors active:scale-95 disabled:opacity-60",
|
||||||
|
value === n
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!showHigh && high.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowHigh(true)}
|
||||||
|
className="flex h-12 items-center justify-center rounded-xl border border-dashed border-border bg-background text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
{splitAt + 1}+
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showHigh && (
|
||||||
|
<button type="button" onClick={() => setShowHigh(false)} className="self-start text-xs font-semibold text-primary">
|
||||||
|
← Tilbake
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return iso
|
||||||
|
return new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" }).format(date)
|
||||||
|
}
|
||||||
834
frontend/components/round-new.tsx
Normal file
834
frontend/components/round-new.tsx
Normal file
|
|
@ -0,0 +1,834 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Opprett en frittstående runde (ADR-033). To bane-kilder: offisiell
|
||||||
|
// teeoff-bane (live oppslag, Beslutning C -- ingen lokal kopi lagres) eller
|
||||||
|
// en egendefinert bane (organisasjonsuavhengig katalog, søkbar på tvers av
|
||||||
|
// alle brukere -- samme "søk før du oppretter"-idé som org-banene).
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { ArrowLeft, Check, ChevronRight, Plus, Search, X } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Wordmark } from "@/components/wordmark"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type Gender = "m" | "f"
|
||||||
|
type TeeOption = { name: string; genders: Gender[] }
|
||||||
|
|
||||||
|
type SelectedCourse = {
|
||||||
|
source: "teeoff" | "custom"
|
||||||
|
teeoffFacilitySlug?: string
|
||||||
|
teeoffCourseId?: number
|
||||||
|
personalCourseId?: string
|
||||||
|
name: string
|
||||||
|
tees: TeeOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = "source" | "teeoff" | "custom-search" | "custom-create" | "confirm"
|
||||||
|
|
||||||
|
export function NewRound() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [step, setStep] = useState<Step>("source")
|
||||||
|
const [selected, setSelected] = useState<SelectedCourse | null>(null)
|
||||||
|
const [ownGender, setOwnGender] = useState<Gender | null>(null)
|
||||||
|
const [loadingMe, setLoadingMe] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function loadMe() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/auth/me", { credentials: "include" })
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.replace("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data: { gender: Gender | null } = await res.json()
|
||||||
|
if (!cancelled) setOwnGender(data.gender)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoadingMe(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadMe()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
function pickCourse(course: SelectedCourse) {
|
||||||
|
setSelected(course)
|
||||||
|
setStep("confirm")
|
||||||
|
}
|
||||||
|
|
||||||
|
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-2xl items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href="/rounds"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||||
|
Egne runder
|
||||||
|
</Link>
|
||||||
|
<Wordmark compact />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-8 sm:py-10">
|
||||||
|
<h1 className="mb-6 text-2xl font-extrabold tracking-tight text-foreground">Ny runde</h1>
|
||||||
|
|
||||||
|
{loadingMe ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : !ownGender ? (
|
||||||
|
<div className="rounded-2xl border border-border bg-card p-5 text-sm text-muted-foreground">
|
||||||
|
Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "}
|
||||||
|
<Link href="/account" className="font-semibold text-primary underline underline-offset-2">
|
||||||
|
Gå til kontoinnstillinger
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
) : step === "source" ? (
|
||||||
|
<SourceChoice
|
||||||
|
onPickTeeoff={() => setStep("teeoff")}
|
||||||
|
onPickCustom={() => setStep("custom-search")}
|
||||||
|
/>
|
||||||
|
) : step === "teeoff" ? (
|
||||||
|
<TeeoffCourseSearch onBack={() => setStep("source")} onPick={pickCourse} />
|
||||||
|
) : step === "custom-search" ? (
|
||||||
|
<CustomCourseSearch
|
||||||
|
onBack={() => setStep("source")}
|
||||||
|
onPick={pickCourse}
|
||||||
|
onCreateNew={() => setStep("custom-create")}
|
||||||
|
/>
|
||||||
|
) : step === "custom-create" ? (
|
||||||
|
<CustomCourseCreate onBack={() => setStep("custom-search")} onCreated={pickCourse} />
|
||||||
|
) : selected ? (
|
||||||
|
<ConfirmRound course={selected} ownGender={ownGender} onBack={() => setStep("source")} />
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Steg 1: velg kilde ------------------------------------------------------
|
||||||
|
|
||||||
|
function SourceChoice({ onPickTeeoff, onPickCustom }: { onPickTeeoff: () => void; onPickCustom: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Hvor spilte du runden?
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPickTeeoff}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-bold text-foreground">Offisiell bane</span>
|
||||||
|
<span className="text-sm text-muted-foreground">Søk opp klubben i teeoff sitt register</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPickCustom}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-base font-bold text-foreground">Egen bane</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Banen finnes ikke i teeoff -- søk opp eller opprett den selv
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Steg 2a: teeoff-søk -----------------------------------------------------
|
||||||
|
|
||||||
|
type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
|
||||||
|
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: TeeOption[] }
|
||||||
|
|
||||||
|
function TeeoffCourseSearch({
|
||||||
|
onBack,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
onBack: () => void
|
||||||
|
onPick: (course: SelectedCourse) => void
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [facilities, setFacilities] = useState<ApiFacility[] | null>(null)
|
||||||
|
const [selectedFacility, setSelectedFacility] = useState<ApiFacility | null>(null)
|
||||||
|
const [courses, setCourses] = useState<ApiOfficialCourseOption[] | null>(null)
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
setSearching(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/rounds/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: ApiFacility) {
|
||||||
|
setSelectedFacility(facility)
|
||||||
|
setError(null)
|
||||||
|
setCourses(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" })
|
||||||
|
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
|
||||||
|
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
|
||||||
|
setCourses(detail.courses)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å hente baner for dette anlegget.")
|
||||||
|
setCourses([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{!selectedFacility ? (
|
||||||
|
<>
|
||||||
|
<BackLink onClick={onBack} label="Bane-kilde" />
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
<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-12 flex-1 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={runSearch} disabled={searching} className="h-12 shrink-0 rounded-xl font-bold">
|
||||||
|
<Search aria-hidden="true" className="size-4" />
|
||||||
|
{searching ? "Søker…" : "Søk"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{facilities && (
|
||||||
|
<ul className="flex flex-col overflow-hidden rounded-2xl 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-3 text-left transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<span className="text-base font-semibold text-foreground">{f.name}</span>
|
||||||
|
{(f.city || f.county) && (
|
||||||
|
<span className="text-sm 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BackLink onClick={() => setSelectedFacility(null)} label={selectedFacility.name} />
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
{courses === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Laster baner…</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border">
|
||||||
|
{courses.map((c) => (
|
||||||
|
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onPick({
|
||||||
|
source: "teeoff",
|
||||||
|
teeoffFacilitySlug: selectedFacility.slug,
|
||||||
|
teeoffCourseId: c.teeoff_course_id,
|
||||||
|
name: `${selectedFacility.name} – ${c.name}`,
|
||||||
|
tees: c.tees,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<span className="text-base font-semibold text-foreground">{c.name}</span>
|
||||||
|
<ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{courses.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
||||||
|
Ingen 18-hulls baner registrert hos dette anlegget ennå.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Steg 2b: egen bane -- søk eksisterende ---------------------------------
|
||||||
|
|
||||||
|
type ApiPersonalCourse = { id: string; name: string }
|
||||||
|
|
||||||
|
function CustomCourseSearch({
|
||||||
|
onBack,
|
||||||
|
onPick,
|
||||||
|
onCreateNew,
|
||||||
|
}: {
|
||||||
|
onBack: () => void
|
||||||
|
onPick: (course: SelectedCourse) => void
|
||||||
|
onCreateNew: () => void
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [results, setResults] = useState<ApiPersonalCourse[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [resolving, setResolving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
|
||||||
|
if (!res.ok) throw new Error(`search: ${res.status}`)
|
||||||
|
const data: ApiPersonalCourse[] = await res.json()
|
||||||
|
if (!cancelled) setResults(data)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("Klarte ikke å søke i egendefinerte baner akkurat nå.")
|
||||||
|
}
|
||||||
|
}, 250)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
async function pick(course: ApiPersonalCourse) {
|
||||||
|
setResolving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
|
||||||
|
if (!res.ok) throw new Error(`detail: ${res.status}`)
|
||||||
|
const detail: { tees: TeeOption[] } = await res.json()
|
||||||
|
onPick({ source: "custom", personalCourseId: course.id, name: course.name, tees: detail.tees })
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å hente banedetaljer. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setResolving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<BackLink onClick={onBack} label="Bane-kilde" />
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Søk egendefinert bane…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="h-12 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border">
|
||||||
|
{results.map((c) => (
|
||||||
|
<li key={c.id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={resolving}
|
||||||
|
onClick={() => pick(c)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/60 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="text-base font-semibold text-foreground">{c.name}</span>
|
||||||
|
<ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{results.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
||||||
|
{query.trim() ? "Ingen treff." : "Skriv for å søke, eller opprett en ny bane under."}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCreateNew}
|
||||||
|
className="h-12 self-start rounded-2xl border-dashed text-base font-bold"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-5" />
|
||||||
|
Opprett ny bane
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Steg 2c: egen bane -- opprett ny ---------------------------------------
|
||||||
|
|
||||||
|
const DEFAULT_PARS = [4, 4, 3, 5, 4, 4, 3, 5, 4, 4, 4, 3, 5, 4, 4, 3, 5, 4]
|
||||||
|
|
||||||
|
type HoleDraft = { par: number; strokeIndex: number }
|
||||||
|
type TeeRatingDraft = { courseRating: string; slopeRating: string; par: string }
|
||||||
|
type TeeDraft = { name: string; m: TeeRatingDraft | null; f: TeeRatingDraft | null }
|
||||||
|
|
||||||
|
function CustomCourseCreate({
|
||||||
|
onBack,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
onBack: () => void
|
||||||
|
onCreated: (course: SelectedCourse) => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [holes, setHoles] = useState<HoleDraft[]>(
|
||||||
|
DEFAULT_PARS.map((par, i) => ({ par, strokeIndex: i + 1 })),
|
||||||
|
)
|
||||||
|
const [tees, setTees] = useState<TeeDraft[]>([
|
||||||
|
{ name: "Gul", m: { courseRating: "", slopeRating: "", par: "72" }, f: null },
|
||||||
|
])
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const parSum = holes.reduce((s, h) => s + h.par, 0)
|
||||||
|
const indexPermutationValid = new Set(holes.map((h) => h.strokeIndex)).size === 18
|
||||||
|
|
||||||
|
function updateHole(i: number, patch: Partial<HoleDraft>) {
|
||||||
|
setHoles((prev) => prev.map((h, idx) => (idx === i ? { ...h, ...patch } : h)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTee(i: number, patch: Partial<TeeDraft>) {
|
||||||
|
setTees((prev) => prev.map((t, idx) => (idx === i ? { ...t, ...patch } : t)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const teesValid = tees.every(
|
||||||
|
(t) =>
|
||||||
|
t.name.trim().length > 0 &&
|
||||||
|
(t.m || t.f) &&
|
||||||
|
[t.m, t.f].every((r) => !r || (r.courseRating.trim() && r.slopeRating.trim() && r.par.trim())),
|
||||||
|
)
|
||||||
|
const canSubmit = name.trim().length >= 2 && indexPermutationValid && teesValid && !submitting
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: name.trim(),
|
||||||
|
holes: holes.map((h, i) => ({ hole_number: i + 1, par: h.par, stroke_index: h.strokeIndex })),
|
||||||
|
tees: tees.map((t) => ({
|
||||||
|
name: t.name.trim(),
|
||||||
|
ratings: [
|
||||||
|
...(t.m
|
||||||
|
? [{ gender: "m", course_rating: Number(t.m.courseRating), slope_rating: Number(t.m.slopeRating), par: Number(t.m.par) }]
|
||||||
|
: []),
|
||||||
|
...(t.f
|
||||||
|
? [{ gender: "f", course_rating: Number(t.f.courseRating), slope_rating: Number(t.f.slopeRating), par: Number(t.f.par) }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
const res = await fetch("/personal-courses", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`create: ${res.status}`)
|
||||||
|
const created: { id: string; name: string } = await res.json()
|
||||||
|
onCreated({
|
||||||
|
source: "custom",
|
||||||
|
personalCourseId: created.id,
|
||||||
|
name: created.name,
|
||||||
|
tees: tees.map((t) => ({
|
||||||
|
name: t.name.trim(),
|
||||||
|
genders: [...(t.m ? (["m"] as const) : []), ...(t.f ? (["f"] as const) : [])],
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||||
|
<BackLink onClick={onBack} label="Søk egen bane" />
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="course-name" className="text-sm font-semibold">
|
||||||
|
Navn på banen
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="course-name"
|
||||||
|
autoFocus
|
||||||
|
placeholder="F.eks. Min Golfklubb – Hovedbanen"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="h-12 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<h2 className="text-base font-bold text-foreground">18 hull</h2>
|
||||||
|
<span className={cn("text-sm font-semibold", parSum === 72 ? "text-muted-foreground" : "text-foreground")}>
|
||||||
|
Sum par: {parSum}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!indexPermutationValid && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-destructive">
|
||||||
|
Hver stroke-indeks (1–18) må brukes nøyaktig én gang.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{holes.map((h, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 rounded-xl border border-border bg-card p-2.5">
|
||||||
|
<span className="w-16 shrink-0 text-sm font-bold text-foreground">Hull {i + 1}</span>
|
||||||
|
<label className="flex flex-1 items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Par
|
||||||
|
<select
|
||||||
|
value={h.par}
|
||||||
|
onChange={(e) => updateHole(i, { par: Number(e.target.value) })}
|
||||||
|
className="h-10 flex-1 rounded-lg border border-border bg-background px-2 text-base font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{[3, 4, 5, 6].map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-1 items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Idx
|
||||||
|
<select
|
||||||
|
value={h.strokeIndex}
|
||||||
|
onChange={(e) => updateHole(i, { strokeIndex: Number(e.target.value) })}
|
||||||
|
className="h-10 flex-1 rounded-lg border border-border bg-background px-2 text-base font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 18 }, (_, n) => n + 1).map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Utslag / rating</h2>
|
||||||
|
{tees.map((t, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={t.name}
|
||||||
|
onChange={(e) => updateTee(i, { name: e.target.value })}
|
||||||
|
placeholder="Navn på utslag (f.eks. Gul)"
|
||||||
|
className="h-11 flex-1 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
{tees.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTees((prev) => prev.filter((_, idx) => idx !== i))}
|
||||||
|
className="size-10 shrink-0 rounded-xl text-muted-foreground"
|
||||||
|
aria-label="Fjern utslag"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<GenderRatingFields
|
||||||
|
label="Herre-rating"
|
||||||
|
value={t.m}
|
||||||
|
onChange={(v) => updateTee(i, { m: v })}
|
||||||
|
/>
|
||||||
|
<GenderRatingFields
|
||||||
|
label="Dame-rating"
|
||||||
|
value={t.f}
|
||||||
|
onChange={(v) => updateTee(i, { f: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setTees((prev) => [...prev, { name: "", m: null, f: null }])}
|
||||||
|
className="h-11 self-start rounded-xl border-dashed text-sm font-bold"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Legg til utslag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={!canSubmit} className="h-14 rounded-2xl text-base font-bold shadow-sm">
|
||||||
|
{submitting ? "Oppretter…" : "Opprett bane og fortsett"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenderRatingFields({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: TeeRatingDraft | null
|
||||||
|
onChange: (v: TeeRatingDraft | null) => void
|
||||||
|
}) {
|
||||||
|
const id = label.toLowerCase().replace(/\s+/g, "-")
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-xl border border-border/60 p-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value !== null}
|
||||||
|
onChange={(e) => onChange(e.target.checked ? { courseRating: "", slopeRating: "", par: "72" } : null)}
|
||||||
|
className="size-5 rounded border-border"
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{value && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor={`${id}-cr`} className="text-xs text-muted-foreground">
|
||||||
|
Course rating
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-cr`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={value.courseRating}
|
||||||
|
onChange={(e) => onChange({ ...value, courseRating: e.target.value })}
|
||||||
|
className="h-10 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor={`${id}-slope`} className="text-xs text-muted-foreground">
|
||||||
|
Slope
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-slope`}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={value.slopeRating}
|
||||||
|
onChange={(e) => onChange({ ...value, slopeRating: e.target.value })}
|
||||||
|
className="h-10 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor={`${id}-par`} className="text-xs text-muted-foreground">
|
||||||
|
Par
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-par`}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={value.par}
|
||||||
|
onChange={(e) => onChange({ ...value, par: e.target.value })}
|
||||||
|
className="h-10 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Steg 3: bekreft + opprett runde -----------------------------------------
|
||||||
|
|
||||||
|
function ConfirmRound({
|
||||||
|
course,
|
||||||
|
ownGender,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
course: SelectedCourse
|
||||||
|
ownGender: Gender
|
||||||
|
onBack: () => void
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const compatibleTees = course.tees.filter((t) => t.genders.includes(ownGender))
|
||||||
|
const [teeName, setTeeName] = useState(compatibleTees[0]?.name ?? "")
|
||||||
|
const [playedAt, setPlayedAt] = useState(() => new Date().toISOString().slice(0, 10))
|
||||||
|
const [startHole, setStartHole] = useState(1)
|
||||||
|
const [holesPlanned, setHolesPlanned] = useState<9 | 18>(18)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!teeName) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/rounds", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
course_source: course.source,
|
||||||
|
teeoff_facility_slug: course.teeoffFacilitySlug,
|
||||||
|
teeoff_course_id: course.teeoffCourseId,
|
||||||
|
personal_course_id: course.personalCourseId,
|
||||||
|
tee_name: teeName,
|
||||||
|
played_at: playedAt,
|
||||||
|
start_hole: startHole,
|
||||||
|
holes_planned: holesPlanned,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`create round: ${res.status}`)
|
||||||
|
const created: { id: string } = await res.json()
|
||||||
|
router.replace(`/rounds/${created.id}`)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å opprette runden. Prøv igjen.")
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<BackLink onClick={onBack} label="Bane-kilde" />
|
||||||
|
<div className="rounded-2xl border border-border bg-card p-4">
|
||||||
|
<span className="text-base font-bold text-foreground">{course.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{compatibleTees.length === 0 ? (
|
||||||
|
<p role="alert" className="text-sm font-medium text-destructive">
|
||||||
|
Denne banen har ingen registrert rating for ditt kjønn på noe utslag -- HCP-sporing er ikke mulig for
|
||||||
|
denne runden. Velg en annen bane, eller fullfør profilen din på nytt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-semibold">Utslag</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{compatibleTees.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTeeName(t.name)}
|
||||||
|
aria-pressed={teeName === t.name}
|
||||||
|
className={cn(
|
||||||
|
"h-11 rounded-xl border px-4 text-base font-semibold transition-colors",
|
||||||
|
teeName === t.name
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="played-at" className="text-sm font-semibold">
|
||||||
|
Dato
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="played-at"
|
||||||
|
type="date"
|
||||||
|
value={playedAt}
|
||||||
|
onChange={(e) => setPlayedAt(e.target.value)}
|
||||||
|
className="h-12 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="start-hole" className="text-sm font-semibold">
|
||||||
|
Starthull
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="start-hole"
|
||||||
|
value={startHole}
|
||||||
|
onChange={(e) => setStartHole(Number(e.target.value))}
|
||||||
|
className="h-12 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 18 }, (_, i) => i + 1).map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
Hull {n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-semibold">Antall hull</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{([9, 18] as const).map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHolesPlanned(n)}
|
||||||
|
aria-pressed={holesPlanned === n}
|
||||||
|
className={cn(
|
||||||
|
"h-12 flex-1 rounded-xl border text-base font-bold transition-colors",
|
||||||
|
holesPlanned === n
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{n} hull
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={submitting || compatibleTees.length === 0 || !teeName}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="h-14 rounded-2xl text-base font-bold shadow-sm"
|
||||||
|
>
|
||||||
|
<Check aria-hidden="true" className="size-5" />
|
||||||
|
{submitting ? "Oppretter…" : "Start runden"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delt --------------------------------------------------------------------
|
||||||
|
|
||||||
|
function BackLink({ onClick, label }: { onClick: () => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex items-center gap-1.5 self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue