Update Todos
Diffe zip 9 mot live-treet Integrere rediger/slett i tournament-program.tsx (behold ekte create-flyt, IKKE V0s regresjon) Ekte typesjekket frontend-build Rulle ut live Oppdatere .md-filer + slette zip 9 Rediger/slett-UI for økter er live. Kort oppsummert: Fanget en reell regresjon i selve V0-eksporten før noe ble tatt inn: denne runden hadde utilsiktet fjernet bane-feltet fra «Legg til økt»-skjemaet (nye økter ville fått en hardkodet mock-bane). Diffet mot live-treet som vanlig, tok kun inn de nye rediger/slett-delene, lot det ekte opprett-skjemaet (med ekte bane-søk/teeoff-import) stå urørt. Bane-bytte i redigeringsskjemaet er bevisst enklere enn opprett-skjemaets — kun velg blant eksisterende baner eller hent fra teeoff, ingen «opprett ny bane»-snarvei, som unngår at samme forvekslingsfelle fra Tjøme-hendelsen kan skje igjen. Verifisert med ekte PATCH/DELETE-kall (inkludert at 409-feilmeldingen fra backend vises riktig i UI-et), test_isolation.sql 12/12, typesjekket build. .md-filene oppdatert, zip 9 slettet. Alt fra "bygg i rekkefølgen ting brukes"-serien pluss denne rundens opprydninger er nå live og konsistente.
This commit is contained in:
parent
459f3f8e14
commit
8a95ef8af2
4 changed files with 542 additions and 47 deletions
|
|
@ -270,7 +270,9 @@
|
||||||
"Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_db -v ON_ERROR_STOP=1 -c ' *)",
|
"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(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(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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -1040,6 +1040,33 @@ Ferdig og verifisert:
|
||||||
Ingen migrasjon, kun `teecup_api` redeployet, `teeoff.no` upåvirket.
|
Ingen migrasjon, kun `teecup_api` redeployet, `teeoff.no` upåvirket.
|
||||||
Frontend (rediger-/slett-knapper i program-skjermen) ikke bygget ennå —
|
Frontend (rediger-/slett-knapper i program-skjermen) ikke bygget ennå —
|
||||||
kun backend-kapasiteten denne runden.
|
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:
|
Neste steg:
|
||||||
1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
||||||
|
|
|
||||||
|
|
@ -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
|
(`teecup_api`, `teecup_frontend`) bygget og redeployet, live sjekker OK
|
||||||
(`/health`, `/dashboard` → 200), `teeoff.no` upåvirket.
|
(`/health`, `/dashboard` → 200), `teeoff.no` upåvirket.
|
||||||
|
|
||||||
**Nytt 2026-07-18, ✅ BACKEND LIVE (frontend gjenstår):** `PATCH`/`DELETE`
|
**Nytt 2026-07-18, ✅ HELT FERDIG (backend + frontend live):** `PATCH`/
|
||||||
for økter (`app/routers/tournaments.py`) — kunne tidligere verken rettes
|
`DELETE` for økter (`app/routers/tournaments.py`) — kunne tidligere verken
|
||||||
eller slettes etter opprettelse. `DELETE` kun for tomme økter (409 hvis den
|
rettes eller slettes etter opprettelse. `DELETE` kun for tomme økter (409
|
||||||
har matcher). `PATCH` dekker enkle felt fritt, pluss en egen, forsiktig
|
hvis den har matcher). `PATCH` dekker enkle felt fritt, pluss en egen,
|
||||||
gren for bane-bytte (finner/flytter tilsvarende tee per allerede tillagt
|
forsiktig gren for bane-bytte (finner/flytter tilsvarende tee per allerede
|
||||||
deltaker, regner om handicap+matchstatus for hele økten etterpå — også for
|
tillagt deltaker, regner om handicap+matchstatus for hele økten etterpå —
|
||||||
allerede AVGJORTE matcher, bekreftet eksplisitt av bruker). Se
|
også for allerede AVGJORTE matcher, bekreftet eksplisitt av bruker). Se
|
||||||
CLAUDE.md-status for det fulle scenarioet (verifisert med et 10-hulls
|
CLAUDE.md-status for det fulle scenarioet (verifisert med et 10-hulls
|
||||||
avgjort-match-eksempel) og et urelatert funn (`front_9`/`back_9` +
|
avgjort-match-eksempel) og et urelatert funn (`front_9`/`back_9` +
|
||||||
`stroke`-modus kan aldri få handicap i dag, siden `tee_rating` alltid kun
|
`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
|
lages med `full_18`-omfang).
|
||||||
bygget ennå.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,28 @@ import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Clock,
|
Clock,
|
||||||
Flag,
|
Flag,
|
||||||
|
MapPin,
|
||||||
|
MoreVertical,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) -
|
// --- 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<string, unknown>): Promise<boolean> {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`update session: ${res.status}`)
|
||||||
|
const updated: ApiSession = await res.json()
|
||||||
|
setSessions((prev) => prev.map((s) => (s.id === sessionId ? updated : s)))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å oppdatere økten. Prøv igjen.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSession(sessionId: string): Promise<string | null> {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (res.status === 204) {
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== sessionId))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
return body?.detail?.message ?? "Klarte ikke å slette økten. Prøv igjen."
|
||||||
|
} catch {
|
||||||
|
return "Klarte ikke å slette økten. Prøv igjen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
const programHref = `/tournaments/${tournamentId}/program?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)}`
|
const leaderboardHref = `/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
|
@ -277,8 +325,13 @@ export function TournamentProgram({
|
||||||
key={session.id}
|
key={session.id}
|
||||||
session={session}
|
session={session}
|
||||||
course={coursesById.get(session.course_id) ?? null}
|
course={coursesById.get(session.course_id) ?? null}
|
||||||
|
courses={courses}
|
||||||
|
organizationId={organizationId}
|
||||||
isLast={index === sorted.length - 1}
|
isLast={index === sorted.length - 1}
|
||||||
href={`/tournaments/${tournamentId}/sessions/${session.id}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
href={`/tournaments/${tournamentId}/sessions/${session.id}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||||
|
onCourseImported={(course) => setCourses((prev) => [...prev, course])}
|
||||||
|
onSave={(patch) => updateSession(session.id, patch)}
|
||||||
|
onDelete={() => deleteSession(session.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
@ -305,16 +358,40 @@ export function TournamentProgram({
|
||||||
function SessionCard({
|
function SessionCard({
|
||||||
session,
|
session,
|
||||||
course,
|
course,
|
||||||
|
courses,
|
||||||
|
organizationId,
|
||||||
isLast,
|
isLast,
|
||||||
href,
|
href,
|
||||||
|
onCourseImported,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
session: ApiSession
|
session: ApiSession
|
||||||
course: ApiCourse | null
|
course: ApiCourse | null
|
||||||
|
courses: ApiCourse[]
|
||||||
|
organizationId: string
|
||||||
isLast: boolean
|
isLast: boolean
|
||||||
href: string
|
href: string
|
||||||
|
onCourseImported: (course: ApiCourse) => void
|
||||||
|
onSave: (patch: Record<string, unknown>) => Promise<boolean>
|
||||||
|
onDelete: () => Promise<string | null>
|
||||||
}) {
|
}) {
|
||||||
|
const [mode, setMode] = useState<"view" | "editing" | "confirmingDelete">("view")
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
const title = session.name?.trim() || `Økt ${session.sequence}`
|
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 (
|
return (
|
||||||
<li className="relative flex gap-4">
|
<li className="relative flex gap-4">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
|
|
@ -324,11 +401,23 @@ function SessionCard({
|
||||||
{!isLast && <div aria-hidden="true" className="mt-1 w-0.5 flex-1 rounded-full bg-border" />}
|
{!isLast && <div aria-hidden="true" className="mt-1 w-0.5 flex-1 rounded-full bg-border" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
{mode === "editing" ? (
|
||||||
href={href}
|
<EditSessionForm
|
||||||
className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50"
|
session={session}
|
||||||
>
|
course={course}
|
||||||
<div className="flex flex-col gap-1">
|
courses={courses}
|
||||||
|
organizationId={organizationId}
|
||||||
|
onCourseImported={onCourseImported}
|
||||||
|
onCancel={() => setMode("view")}
|
||||||
|
onSave={async (patch) => {
|
||||||
|
const ok = await onSave(patch)
|
||||||
|
if (ok) setMode("view")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<Link href={href} className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3>
|
<h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
|
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
|
||||||
|
|
@ -337,9 +426,37 @@ function SessionCard({
|
||||||
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
|
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
|
||||||
{course && <MetaBadge>{course.name}</MetaBadge>}
|
{course && <MetaBadge>{course.name}</MetaBadge>}
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
aria-label={`Handlinger for ${title}`}
|
||||||
|
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<MoreVertical aria-hidden="true" className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44 rounded-2xl p-1.5">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setMode("editing")}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden="true" className="size-4" />
|
||||||
|
Rediger
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteError(null)
|
||||||
|
setMode("confirmingDelete")
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" className="size-4" />
|
||||||
|
Slett
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
|
<Link href={href} className="flex flex-col gap-1.5 border-t border-border pt-3">
|
||||||
{session.scheduled_at ? (
|
{session.scheduled_at ? (
|
||||||
<div className="flex items-start gap-2 text-sm text-foreground">
|
<div className="flex items-start gap-2 text-sm text-foreground">
|
||||||
<CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
|
<CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
|
|
@ -362,12 +479,356 @@ function SessionCard({
|
||||||
<span>Starter på hull {session.start_hole}</span>
|
<span>Starter på hull {session.start_hole}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{mode === "confirmingDelete" &&
|
||||||
|
(deleteError ? (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-muted/50 p-4">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{deleteError}</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 rounded-xl"
|
||||||
|
onClick={() => setMode("view")}
|
||||||
|
>
|
||||||
|
Lukk
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 p-4">
|
||||||
|
<p className="text-sm leading-relaxed text-foreground text-pretty">
|
||||||
|
Er du sikker på at du vil slette denne økten? Dette kan ikke angres.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="h-9 rounded-xl"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{deleting ? "Sletter…" : "Slett"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 rounded-xl"
|
||||||
|
onClick={() => setMode("view")}
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Edit session form -------------------------------------------------------
|
||||||
|
|
||||||
|
function EditSessionForm({
|
||||||
|
session,
|
||||||
|
course,
|
||||||
|
courses,
|
||||||
|
organizationId,
|
||||||
|
onCourseImported,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
session: ApiSession
|
||||||
|
course: ApiCourse | null
|
||||||
|
courses: ApiCourse[]
|
||||||
|
organizationId: string
|
||||||
|
onCourseImported: (course: ApiCourse) => void
|
||||||
|
onSave: (patch: Record<string, unknown>) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(session.name ?? "")
|
||||||
|
const [points, setPoints] = useState(String(session.points_per_match))
|
||||||
|
const [startHole, setStartHole] = useState(String(session.start_hole))
|
||||||
|
const [scheduledAt, setScheduledAt] = useState(session.scheduled_at?.slice(0, 16) ?? "")
|
||||||
|
const [teeInterval, setTeeInterval] = useState(
|
||||||
|
session.tee_interval_minutes ? String(session.tee_interval_minutes) : "",
|
||||||
|
)
|
||||||
|
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 (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="mb-1 flex flex-1 flex-col gap-6 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
||||||
|
<Pencil aria-hidden="true" className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Rediger økt {session.sequence}
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-bold text-foreground">
|
||||||
|
{session.name?.trim() || `Økt ${session.sequence}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`edit-name-${uid}`} className="text-sm font-semibold">
|
||||||
|
Navn <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`edit-name-${uid}`}
|
||||||
|
placeholder="F.eks. Foursome dag 1"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-semibold">Bane</Label>
|
||||||
|
<EditCourseField
|
||||||
|
organizationId={organizationId}
|
||||||
|
courses={courses}
|
||||||
|
currentCourseId={courseId}
|
||||||
|
currentCourseName={courseName}
|
||||||
|
onCourseImported={onCourseImported}
|
||||||
|
onChange={(id, name) => {
|
||||||
|
setCourseId(id)
|
||||||
|
setCourseName(name)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`edit-points-${uid}`} className="text-sm font-semibold">
|
||||||
|
Poeng per match
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`edit-points-${uid}`}
|
||||||
|
inputMode="decimal"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
value={points}
|
||||||
|
onChange={(e) => setPoints(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`edit-starthole-${uid}`} className="text-sm font-semibold">
|
||||||
|
Starthull
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`edit-starthole-${uid}`}
|
||||||
|
inputMode="numeric"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="18"
|
||||||
|
value={startHole}
|
||||||
|
onChange={(e) => setStartHole(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`edit-time-${uid}`} className="text-sm font-semibold">
|
||||||
|
Startdato og -klokkeslett{" "}
|
||||||
|
<span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`edit-time-${uid}`}
|
||||||
|
type="datetime-local"
|
||||||
|
value={scheduledAt}
|
||||||
|
onChange={(e) => setScheduledAt(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasTime && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`edit-interval-${uid}`} className="text-sm font-semibold">
|
||||||
|
Minutter mellom hver flight
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`edit-interval-${uid}`}
|
||||||
|
inputMode="numeric"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="F.eks. 10"
|
||||||
|
value={teeInterval}
|
||||||
|
onChange={(e) => setTeeInterval(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="submit" disabled={submitting} className="h-12 flex-1 rounded-2xl text-base font-bold shadow-sm">
|
||||||
|
{submitting ? "Lagrer…" : "Lagre"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="h-12 rounded-2xl px-6 text-base font-semibold"
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bane-bytte i redigeringsskjemaet (kun eksisterende baner -- PATCH sin
|
||||||
|
// course_id må være en ekte, allerede opprettet bane, ikke en "opprett
|
||||||
|
// ny"-tekststreng slik opprett-skjemaet tillater) -------------------------
|
||||||
|
|
||||||
|
function EditCourseField({
|
||||||
|
organizationId,
|
||||||
|
courses,
|
||||||
|
currentCourseId,
|
||||||
|
currentCourseName,
|
||||||
|
onCourseImported,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
courses: ApiCourse[]
|
||||||
|
currentCourseId: string
|
||||||
|
currentCourseName: string
|
||||||
|
onCourseImported: (course: ApiCourse) => void
|
||||||
|
onChange: (courseId: string, courseName: string) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1.5 text-sm font-semibold text-secondary-foreground">
|
||||||
|
<MapPin aria-hidden="true" className="size-4 text-primary" />
|
||||||
|
{currentCourseName || "Ukjent bane"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 rounded-xl"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("")
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bytt bane
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const matches = courses.filter((c) => c.name.toLowerCase().includes(q))
|
||||||
|
|
||||||
|
function select(c: ApiCourse) {
|
||||||
|
onChange(c.id, c.name)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background/50 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Søk blant organisasjonens baner…"
|
||||||
|
className="h-11 flex-1 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="Lukk banesøk"
|
||||||
|
className="flex size-9 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex max-h-48 flex-col overflow-auto rounded-xl border border-border">
|
||||||
|
{matches.map((c) => (
|
||||||
|
<li key={c.id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => select(c)}
|
||||||
|
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<MapPin aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
{c.id === currentCourseId && <Check aria-hidden="true" className="size-4 text-primary" />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{matches.length === 0 && (
|
||||||
|
<li className="px-3 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{!officialSearchOpen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOfficialSearchOpen(true)}
|
||||||
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Hent bane fra teeoff i stedet
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<OfficialCourseSearch
|
||||||
|
organizationId={organizationId}
|
||||||
|
onClose={() => setOfficialSearchOpen(false)}
|
||||||
|
onImported={(newCourse) => {
|
||||||
|
onCourseImported(newCourse)
|
||||||
|
select(newCourse)
|
||||||
|
setOfficialSearchOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MetaBadge({ children }: { children: React.ReactNode }) {
|
function MetaBadge({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
|
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue