diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 834a4fe..c4866dc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -270,7 +270,9 @@ "Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_db -v ON_ERROR_STOP=1 -c ' *)", "Bash(python3 -m py_compile /opt/teecup/app/routers/tournaments.py /opt/teecup/app/routers/scoring.py)", "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/patch_cookies.txt)", - "Bash(awk 'NR==499,/^---$/{print NR\": \"$0}' /opt/teecup/FEATURE_BACKLOG.md)" + "Bash(awk 'NR==499,/^---$/{print NR\": \"$0}' /opt/teecup/FEATURE_BACKLOG.md)", + "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-9)", + "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/ui_cookies.txt)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/CLAUDE.md b/CLAUDE.md index f9a9329..d92daee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1040,6 +1040,33 @@ Ferdig og verifisert: Ingen migrasjon, kun `teecup_api` redeployet, `teeoff.no` upåvirket. Frontend (rediger-/slett-knapper i program-skjermen) ikke bygget ennå — kun backend-kapasiteten denne runden. +- **Rediger/slett-UI for økter LIVE (2026-07-19):** V0-utvidelse av den + eksisterende Program-skjermen (ingen ny rute) — "..."-meny (samme + DropdownMenu-mønster som roster-skjermens per-spiller-handlinger) med + "Rediger"/"Slett" per øktkort. Rediger bytter kortet til et inline-skjema + (samme felt som PATCH støtter: navn, poeng, starthull, klokkeslett/ + intervall, bane). Slett viser en bekreftelse; treffer den ekte 409-en + (økt har matcher), vises backend sin egen feiltekst i stedet for en + forhåndsberegnet klient-tilstand. + **Reell regresjon funnet OG UNNGÅTT i selve V0-eksporten, ikke i + etterkant:** denne rundens V0-prompt handlet kun om rediger/slett, men + eksporten hadde samtidig (utilsiktet) FJERNET bane-feltet fra "Legg til + økt"-skjemaet helt — nye økter ville stille blitt satt til en hardkodet + mock-bane. Fanget under diff-mot-live-treet FØR noe ble tatt inn (samme + rutine som alltid) — `CreateSessionCard` (med sitt ekte bane-søk/ + teeoff-import) beholdt fullstendig urørt; kun de nye rediger/slett- + delene ble hentet inn. + **Bane-bytte i redigeringsskjemaet er bevisst ENKLERE enn opprett- + skjemaets bane-felt** — kun velg blant organisasjonens eksisterende + baner eller hent fra teeoff, INGEN "opprett ny bane: X"-snarvei her + (PATCH sin `course_id` må være en ekte, allerede eksisterende bane, ikke + en tekststreng) — unngår at samme "Tj"-forvekslingsfelle fra forrige + banebytte-hendelse kan gjenta seg via redigeringsveien. + **Verifisert:** ekte PATCH med nøyaktig skjemaets feltform, ekte DELETE + (204 tom økt, 409 med matcher — bekreftet at `{"detail":{"code", + "message"}}`-formen leses riktig av UI-et), `test_isolation.sql` 12/12, + ekte typesjekket build. Rullet ut live, ren frontend-endring, `teeoff.no` + upåvirket. Neste steg: 1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 42ce37e..06064fd 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -564,18 +564,23 @@ andre skjermen når den ble generert i en egen prompt. (`teecup_api`, `teecup_frontend`) bygget og redeployet, live sjekker OK (`/health`, `/dashboard` → 200), `teeoff.no` upåvirket. -**Nytt 2026-07-18, ✅ BACKEND LIVE (frontend gjenstår):** `PATCH`/`DELETE` -for økter (`app/routers/tournaments.py`) — kunne tidligere verken rettes -eller slettes etter opprettelse. `DELETE` kun for tomme økter (409 hvis den -har matcher). `PATCH` dekker enkle felt fritt, pluss en egen, forsiktig -gren for bane-bytte (finner/flytter tilsvarende tee per allerede tillagt -deltaker, regner om handicap+matchstatus for hele økten etterpå — også for -allerede AVGJORTE matcher, bekreftet eksplisitt av bruker). Se +**Nytt 2026-07-18, ✅ HELT FERDIG (backend + frontend live):** `PATCH`/ +`DELETE` for økter (`app/routers/tournaments.py`) — kunne tidligere verken +rettes eller slettes etter opprettelse. `DELETE` kun for tomme økter (409 +hvis den har matcher). `PATCH` dekker enkle felt fritt, pluss en egen, +forsiktig gren for bane-bytte (finner/flytter tilsvarende tee per allerede +tillagt deltaker, regner om handicap+matchstatus for hele økten etterpå — +også for allerede AVGJORTE matcher, bekreftet eksplisitt av bruker). Se CLAUDE.md-status for det fulle scenarioet (verifisert med et 10-hulls avgjort-match-eksempel) og et urelatert funn (`front_9`/`back_9` + `stroke`-modus kan aldri få handicap i dag, siden `tee_rating` alltid kun -lages med `full_18`-omfang). Rediger-/slett-UI i program-skjermen ikke -bygget ennå. +lages med `full_18`-omfang). +**Rediger-/slett-UI LIVE 2026-07-19** — utvidelse av den eksisterende +Program-skjermen (ingen ny rute). Fanget en reell regresjon i selve +V0-eksporten før den ble tatt inn: samme runde hadde utilsiktet fjernet +bane-feltet fra "Legg til økt"-skjemaet — kun de nye rediger/slett-delene +ble hentet inn, det ekte opprett-skjemaet urørt. Se CLAUDE.md-status for +detaljer. --- diff --git a/frontend/components/tournament-program.tsx b/frontend/components/tournament-program.tsx index 2788f27..3b1667f 100644 --- a/frontend/components/tournament-program.tsx +++ b/frontend/components/tournament-program.tsx @@ -6,17 +6,28 @@ import Link from "next/link" import { ArrowLeft, CalendarClock, + Check, ChevronDown, Clock, Flag, + MapPin, + MoreVertical, + Pencil, Plus, Sparkles, + Trash2, X, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils" // --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) - @@ -196,6 +207,43 @@ export function TournamentProgram({ } } + async function updateSession(sessionId: string, patch: Record): Promise { + 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 { + 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." + } + } + const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` const leaderboardHref = `/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` @@ -277,8 +325,13 @@ export function TournamentProgram({ key={session.id} session={session} course={coursesById.get(session.course_id) ?? null} + courses={courses} + organizationId={organizationId} 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)} /> ))} @@ -305,16 +358,40 @@ export function TournamentProgram({ function SessionCard({ session, course, + courses, + organizationId, isLast, href, + onCourseImported, + onSave, + onDelete, }: { session: ApiSession course: ApiCourse | null + courses: ApiCourse[] + organizationId: string isLast: boolean href: string + onCourseImported: (course: ApiCourse) => void + onSave: (patch: Record) => Promise + onDelete: () => Promise }) { + const [mode, setMode] = useState<"view" | "editing" | "confirmingDelete">("view") + const [deleteError, setDeleteError] = useState(null) + const [deleting, setDeleting] = useState(false) const title = session.name?.trim() || `Økt ${session.sequence}` + async function handleDelete() { + setDeleting(true) + const message = await onDelete() + setDeleting(false) + if (message) { + setDeleteError(message) + } else { + setMode("view") + } + } + return (
  • @@ -324,50 +401,434 @@ function SessionCard({ {!isLast && - -
    -

    {title}

    -
    - {FORMAT_LABELS[session.format]} - {HOLE_LABELS[session.hole_config]} - {SCORING_LABELS[session.scoring_mode]} - {formatPoints(session.points_per_match)} poeng - {course && {course.name}} + {mode === "editing" ? ( + setMode("view")} + onSave={async (patch) => { + const ok = await onSave(patch) + if (ok) setMode("view") + }} + /> + ) : ( +
    +
    + +

    {title}

    +
    + {FORMAT_LABELS[session.format]} + {HOLE_LABELS[session.hole_config]} + {SCORING_LABELS[session.scoring_mode]} + {formatPoints(session.points_per_match)} poeng + {course && {course.name}} +
    + + + + + + setMode("editing")} + className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold" + > + + { + 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" + > + + +
    -
    -
    - {session.scheduled_at ? ( -
    -
    - ) : ( -
    -
    - )} - {session.start_hole !== 1 && ( -
    -
    - )} + + {session.scheduled_at ? ( +
    +
    + ) : ( +
    +
    + )} + {session.start_hole !== 1 && ( +
    +
    + )} + + + {mode === "confirmingDelete" && + (deleteError ? ( +
    +

    {deleteError}

    +
    + +
    +
    + ) : ( +
    +

    + Er du sikker på at du vil slette denne økten? Dette kan ikke angres. +

    +
    + + +
    +
    + ))}
    - + )}
  • ) } +// --- 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) => Promise + 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) : "", + ) + const [courseId, setCourseId] = useState(session.course_id) + const [courseName, setCourseName] = useState(course?.name ?? "") + const [submitting, setSubmitting] = useState(false) + + const hasTime = scheduledAt.trim() !== "" + const uid = session.id + + 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, + scheduled_at: hasTime ? scheduledAt : null, + tee_interval_minutes: intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null, + course_id: courseId, + }) + setSubmitting(false) + } + + return ( +
    +
    +
    +
    +
    + + Rediger økt {session.sequence} + + + {session.name?.trim() || `Økt ${session.sequence}`} + +
    +
    + +
    + + setName(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
    + +
    + + { + setCourseId(id) + setCourseName(name) + }} + /> +
    + +
    +
    + + setPoints(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
    +
    + + setStartHole(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
    +
    + +
    + + setScheduledAt(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
    + + {hasTime && ( +
    + + setTeeInterval(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
    + )} + +
    + + +
    +
    + ) +} + +// --- 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) + + if (!open) { + return ( +
    + + + +
    + ) + } + + 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 ( +
    +
    + setQuery(e.target.value)} + placeholder="Søk blant organisasjonens baner…" + className="h-11 flex-1 rounded-xl text-base" + /> + +
    + +
      + {matches.map((c) => ( +
    • + +
    • + ))} + {matches.length === 0 && ( +
    • Ingen treff.
    • + )} +
    + + {!officialSearchOpen ? ( + + ) : ( + setOfficialSearchOpen(false)} + onImported={(newCourse) => { + onCourseImported(newCourse) + select(newCourse) + setOfficialSearchOpen(false) + }} + /> + )} +
    + ) +} + function MetaBadge({ children }: { children: React.ReactNode }) { return (