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(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",
|
||||
|
|
|
|||
27
CLAUDE.md
27
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 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)}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
|
|
@ -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<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}`
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true)
|
||||
const message = await onDelete()
|
||||
setDeleting(false)
|
||||
if (message) {
|
||||
setDeleteError(message)
|
||||
} else {
|
||||
setMode("view")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="relative flex gap-4">
|
||||
<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" />}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={href}
|
||||
className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{mode === "editing" ? (
|
||||
<EditSessionForm
|
||||
session={session}
|
||||
course={course}
|
||||
courses={courses}
|
||||
organizationId={organizationId}
|
||||
onCourseImported={onCourseImported}
|
||||
onCancel={() => setMode("view")}
|
||||
onSave={async (patch) => {
|
||||
const ok = await onSave(patch)
|
||||
if (ok) setMode("view")
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
|
||||
|
|
@ -337,9 +426,37 @@ function SessionCard({
|
|||
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
|
||||
{course && <MetaBadge>{course.name}</MetaBadge>}
|
||||
</div>
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`Handlinger for ${title}`}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
<MoreVertical aria-hidden="true" className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44 rounded-2xl p-1.5">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setMode("editing")}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="size-4" />
|
||||
Rediger
|
||||
</DropdownMenuItem>
|
||||
<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 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 ? (
|
||||
<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" />
|
||||
|
|
@ -362,12 +479,356 @@ function SessionCard({
|
|||
<span>Starter på hull {session.start_hole}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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 }) {
|
||||
return (
|
||||
<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