Integrer TournamentSettingsCard (V0-eksport) i Oppsett-fanen

Navn/datoer/synlighet/beskrivelse/påmelding -- rent innbytte mot
props-kontrakten. Fant og fikset samme tidssone-bugklasse som ADR-088
proaktivt denne gangen (mønstergjenkjenning) før den nådde en nettleser.
This commit is contained in:
Erol Haagenrud 2026-08-18 17:00:44 +02:00
parent 18bfe555a0
commit c5ab6a18f2
3 changed files with 572 additions and 0 deletions

View file

@ -12768,3 +12768,46 @@ Neste steg:
**Rullet ut:** venter på bekreftelse (ren backend-tillegg, ingen **Rullet ut:** venter på bekreftelse (ren backend-tillegg, ingen
migrasjon -- kolonnene fantes allerede). migrasjon -- kolonnene fantes allerede).
119. **TournamentSettingsCard integrert (V0-eksport, samme dag) --
2026-08-18.** Punkt 118 sin V0-prompt kjørt av bruker, eksport
lastet opp SAMME dag (samme `Temp-uploads/tee-cup-login-screen.zip`-
filnavn som leaderboardet i punkt 112 -- overskrevet, ikke et nytt
navn). Eksporten traff props-kontrakten
(`TournamentSettingsValues`/`TournamentSettingsCard`) helt eksakt --
rent innbytte. Fjernet ubrukt `Lock`-ikonimport og V0 sin egen
demo-`export default` (prosjektkonvensjon: ingen komponent i denne
kodebasen har en default-eksport).
Orkestrering lagt til i `individual-tournament-detail.tsx`
(`updateSettings`, kortet rendres øverst i Oppsett-fanen, over
Scoringsmetode) -- kortet kaster sin egen feil ved mislykket lagring
(viser den selv), i stedet for det vanlige "fang og vis global
feil"-mønstret de andre PATCH-hjelperne bruker. `ApiTournamentInfo`
utvidet med `name`/`start_date`/`end_date`/`visibility`/
`description`/`registration_*` -- feltene kom allerede med i API-
responsen, bare ikke typet på frontend før nå.
**Samme tidssone-klasse bug som ADR-088 funnet og fikset PROAKTIVT
denne gangen** (mønstergjenkjenning fra forrige runde, ikke en ny
scratch-oppdagelse): `<input type="datetime-local">` for
påmeldingsfrist ville fått nøyaktig samme UTC-mistolkning som
utslagsgruppenes klokkeslett hvis rå strenger ble sendt/vist direkte
-- `toDatetimeLocalValue`/`fromDatetimeLocalValue`-konvertering lagt
til FØR noe traff en nettleser, ikke funnet i etterkant.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Lettere
verifisering enn utslagsgruppene (ADR-088) -- ingen full scratch-
runde denne gangen: en midlertidig lokal `/settingspreviewtmp`-side
(kun i `next dev`, ALDRI committet) rendret kortet med realistiske
mock-verdier, bekreftet radiogruppe-semantikk, at "Når fullt"
faktisk deaktiveres (med forklaring) når maks-antall tømmes, at
"Lagre" korrekt aktiveres/deaktiveres med endringer, og at selve
lagre-runden fullfører og nullstiller "ulagret"-tilstanden. Vurdert
som lavere restrisiko enn utslagsgruppene siden komponentens egen
logikk er enklere (ett objekt, ett lagre-kall, ingen multi-entitet-
tilstandskoreografi) -- selve felt-til-felt-kartleggingen er
likevel IKKE browser-klikket-gjennom mot en ekte backend ennå, kun
lest/resonnert over kode + typesjekk.
**Rullet ut:** venter på bekreftelse (ren frontend, ingen migrasjon).

View file

@ -43,6 +43,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { PlayerImportPanel } from "@/components/player-import-panel" import { PlayerImportPanel } from "@/components/player-import-panel"
import { RoundGroupsPanel } from "@/components/round-groups-panel" import { RoundGroupsPanel } from "@/components/round-groups-panel"
import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -103,10 +104,22 @@ type NewPlayerInput = {
type ApiTournamentInfo = { type ApiTournamentInfo = {
id: string id: string
name: string
status: TournamentStatus status: TournamentStatus
join_code: string join_code: string
format_type: string format_type: string
scoring_method: string | null scoring_method: string | null
// Turneringsinnstillinger (2026-08-18) -- eksisterte alle på backend
// (fullt fungerende, ekte selvregistrerings-logikk i registration.py)
// uten noen UI noe sted til å sette dem, før TournamentSettingsCard.
start_date: string | null
end_date: string | null
visibility: string
description: string | null
registration_deadline: string | null
registration_capacity: number | null
registration_overflow_policy: string
registration_requires_approval: boolean
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30). // KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
bbb_sweep_bonus_enabled: boolean bbb_sweep_bonus_enabled: boolean
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
@ -563,6 +576,45 @@ export function IndividualTournamentDetail({
} }
} }
// Turneringsinnstillinger (2026-08-18, TournamentSettingsCard --
// V0-eksportert, se ADR/CHANGELOG #118). Komponenten viser sin EGEN
// lagre-/feil-tilstand rundt onSave-løftet -- IKKE fang feilen her
// (til forskjell fra de andre optimistiske PATCH-hjelperne over), la
// den kaste videre slik komponenten forventer.
async function updateSettings(next: TournamentSettingsValues) {
if (!tournament) return
const registrationDeadlineIso = fromDatetimeLocalValue(next.registrationDeadline)
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: next.name,
start_date: next.startDate,
end_date: next.endDate,
visibility: next.visibility,
description: next.description || null,
registration_deadline: registrationDeadlineIso,
registration_capacity: next.registrationCapacity,
registration_overflow_policy: next.registrationOverflowPolicy,
registration_requires_approval: next.registrationRequiresApproval,
}),
})
if (!res.ok) throw new Error("Klarte ikke å lagre innstillingene.")
setTournament({
...tournament,
name: next.name,
start_date: next.startDate,
end_date: next.endDate,
visibility: next.visibility,
description: next.description || null,
registration_deadline: registrationDeadlineIso,
registration_capacity: next.registrationCapacity,
registration_overflow_policy: next.registrationOverflowPolicy,
registration_requires_approval: next.registrationRequiresApproval,
})
}
// MANUELL anvendelse (bruker bekreftet eksplisitt 2026-08-18: IKKE // MANUELL anvendelse (bruker bekreftet eksplisitt 2026-08-18: IKKE
// automatisk ved fullspilt runde) -- idempotent på serveren, men likevel // automatisk ved fullspilt runde) -- idempotent på serveren, men likevel
// en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra // en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra
@ -904,6 +956,7 @@ export function IndividualTournamentDetail({
onUpdateFlagMapVisible={updateFlagMapVisible} onUpdateFlagMapVisible={updateFlagMapVisible}
onUpdateCutConfig={updateCutConfig} onUpdateCutConfig={updateCutConfig}
onUpdateTiebreakConfig={updateTiebreakConfig} onUpdateTiebreakConfig={updateTiebreakConfig}
onUpdateSettings={updateSettings}
onApplyCut={applyCut} onApplyCut={applyCut}
onAddParticipant={addParticipant} onAddParticipant={addParticipant}
onAddNewPlayer={addNewPlayerAndParticipant} onAddNewPlayer={addNewPlayerAndParticipant}
@ -967,6 +1020,26 @@ function JoinCodeChip({ code }: { code: string }) {
// Oppsett-fane: scoringsmetode, deltakere, runder // Oppsett-fane: scoringsmetode, deltakere, runder
// ========================================================================= // =========================================================================
// <input type="datetime-local"> krever "YYYY-MM-DDTHH:mm" (ingen sekunder/
// tidssone) -- backend (Pydantic datetime) serialiserer/forventer full ISO
// med tidssone. Samme konverteringsbehov -- og samme tidssone-bug hvis det
// hoppes over -- som allerede funnet og fikset i round-groups-panel.tsx
// (ADR-088): new Date(...).toISOString() FØR sending, nettleserens egen
// lokal-tolkning ved visning.
function toDatetimeLocalValue(iso: string | null): string | null {
if (!iso) return null
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function fromDatetimeLocalValue(value: string | null): string | null {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function SetupTab({ function SetupTab({
base, base,
organizationId, organizationId,
@ -981,6 +1054,7 @@ function SetupTab({
onUpdateCutConfig, onUpdateCutConfig,
onApplyCut, onApplyCut,
onUpdateTiebreakConfig, onUpdateTiebreakConfig,
onUpdateSettings,
onAddParticipant, onAddParticipant,
onAddNewPlayer, onAddNewPlayer,
onImportComplete, onImportComplete,
@ -1008,6 +1082,7 @@ function SetupTab({
onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void> onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void>
onApplyCut: () => Promise<void> onApplyCut: () => Promise<void>
onUpdateTiebreakConfig: (winner: string, field: string) => Promise<void> onUpdateTiebreakConfig: (winner: string, field: string) => Promise<void>
onUpdateSettings: (next: TournamentSettingsValues) => Promise<void>
onAddParticipant: (playerId: string) => Promise<void> onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void> onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onImportComplete: () => Promise<void> onImportComplete: () => Promise<void>
@ -1042,8 +1117,24 @@ function SetupTab({
} }
}, [organizationId]) }, [organizationId])
const settingsValues: TournamentSettingsValues = {
name: tournament.name,
startDate: tournament.start_date,
endDate: tournament.end_date,
visibility: (tournament.visibility as TournamentSettingsValues["visibility"]) ?? "org",
description: tournament.description ?? "",
registrationDeadline: toDatetimeLocalValue(tournament.registration_deadline),
registrationCapacity: tournament.registration_capacity,
registrationOverflowPolicy:
(tournament.registration_overflow_policy as TournamentSettingsValues["registrationOverflowPolicy"]) ??
"waitlist",
registrationRequiresApproval: tournament.registration_requires_approval,
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<TournamentSettingsCard values={settingsValues} onSave={onUpdateSettings} />
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"> <section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<h2 className="text-base font-bold text-foreground">Scoringsmetode</h2> <h2 className="text-base font-bold text-foreground">Scoringsmetode</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty"> <p className="text-sm leading-relaxed text-muted-foreground text-pretty">

View file

@ -0,0 +1,438 @@
"use client"
import { useEffect, useRef, useState } from "react"
import {
Building2,
Check,
Globe,
Loader2,
TriangleAlert,
Users,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
export type TournamentSettingsValues = {
name: string
startDate: string | null // "YYYY-MM-DD", or null if not set
endDate: string | null // "YYYY-MM-DD", or null if not set
visibility: "public" | "org" | "participants"
description: string // "" if empty, never null
registrationDeadline: string | null // ISO datetime ("YYYY-MM-DDTHH:mm"), or null
registrationCapacity: number | null // null = uncapped
registrationOverflowPolicy: "waitlist" | "closed" // only meaningful once a capacity is set
registrationRequiresApproval: boolean
}
const VISIBILITY_OPTIONS: {
value: TournamentSettingsValues["visibility"]
label: string
description: string
icon: typeof Globe
}[] = [
{
value: "public",
label: "Offentlig",
description: "Hvem som helst kan finne turneringen.",
icon: Globe,
},
{
value: "org",
label: "Organisasjonen",
description: "Bare medlemmer av organisasjonen din.",
icon: Building2,
},
{
value: "participants",
label: "Kun deltakere",
description: "Bare de som allerede er påmeldt.",
icon: Users,
},
]
const inputBase =
"h-11 w-full rounded-xl border border-border bg-background px-3 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
export function TournamentSettingsCard({
values,
onSave,
}: {
values: TournamentSettingsValues
onSave: (next: TournamentSettingsValues) => Promise<void>
}) {
const [draft, setDraft] = useState<TournamentSettingsValues>(values)
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// Only re-sync the draft from props when the SAVED values genuinely change
// (e.g. after a real save round-trip), never on an incidental parent
// re-render that hands us a new object with identical contents.
const lastSyncedRef = useRef<string>(JSON.stringify(values))
useEffect(() => {
const serialized = JSON.stringify(values)
if (serialized !== lastSyncedRef.current) {
lastSyncedRef.current = serialized
setDraft(values)
}
}, [values])
const savedFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (savedFadeRef.current) clearTimeout(savedFadeRef.current)
}
}, [])
function patch<K extends keyof TournamentSettingsValues>(
key: K,
value: TournamentSettingsValues[K],
) {
setDraft((prev) => ({ ...prev, [key]: value }))
// Any edit clears a lingering "saved"/"error" indicator.
setStatus((s) => (s === "saving" ? s : "idle"))
setErrorMessage(null)
}
const nameEmpty = draft.name.trim() === ""
const dirty = JSON.stringify(draft) !== JSON.stringify(values)
const capacitySet = draft.registrationCapacity != null
const saving = status === "saving"
const canSave = dirty && !nameEmpty && !saving
async function handleSave() {
if (!canSave) return
setStatus("saving")
setErrorMessage(null)
try {
// Normalize: trim the name; overflow policy is irrelevant without a cap.
const next: TournamentSettingsValues = {
...draft,
name: draft.name.trim(),
}
await onSave(next)
lastSyncedRef.current = JSON.stringify(next)
setDraft(next)
setStatus("saved")
if (savedFadeRef.current) clearTimeout(savedFadeRef.current)
savedFadeRef.current = setTimeout(() => setStatus("idle"), 2600)
} catch (err) {
setStatus("error")
setErrorMessage(
err instanceof Error ? err.message : "Kunne ikke lagre. Prøv igjen.",
)
}
}
return (
<section
aria-labelledby="tsc-heading"
className="rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8 lg:p-7"
>
<header className="mb-6 flex flex-col gap-1">
<h2 id="tsc-heading" className="text-xl font-bold text-foreground lg:text-2xl">
Grunnleggende
</h2>
<p className="text-sm text-muted-foreground text-pretty">
Navn, datoer, synlighet og påmelding for turneringen.
</p>
</header>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Navn — spans full width */}
<div className="flex flex-col gap-2 lg:col-span-2">
<Label htmlFor="tsc-name" className="text-base font-semibold text-foreground">
Navn
</Label>
<Input
id="tsc-name"
value={draft.name}
onChange={(e) => patch("name", e.target.value)}
aria-required="true"
aria-invalid={nameEmpty}
aria-describedby={nameEmpty ? "tsc-name-error" : undefined}
className="h-11 rounded-xl text-base"
placeholder="F.eks. Klubbmesterskapet 2026"
/>
{nameEmpty && (
<p id="tsc-name-error" className="text-sm font-medium text-destructive">
Navn er påkrevd.
</p>
)}
</div>
{/* Datoer — one connected range control */}
<fieldset className="flex flex-col gap-2 lg:col-span-2">
<legend className="mb-2 text-base font-semibold text-foreground">Datoer</legend>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="tsc-start" className="text-sm text-muted-foreground">
Startdato
</Label>
<input
id="tsc-start"
type="date"
value={draft.startDate ?? ""}
max={draft.endDate ?? undefined}
onChange={(e) => patch("startDate", e.target.value || null)}
className={inputBase}
/>
</div>
<span
aria-hidden="true"
className="hidden shrink-0 pb-3 text-muted-foreground sm:block"
>
&ndash;
</span>
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="tsc-end" className="text-sm text-muted-foreground">
Sluttdato
</Label>
<input
id="tsc-end"
type="date"
value={draft.endDate ?? ""}
min={draft.startDate ?? undefined}
onChange={(e) => patch("endDate", e.target.value || null)}
className={inputBase}
/>
</div>
</div>
</fieldset>
{/* Synlighet — real radio group, spans full width */}
<fieldset className="flex flex-col gap-3 lg:col-span-2">
<legend className="text-base font-semibold text-foreground">Synlighet</legend>
<div
role="radiogroup"
aria-label="Synlighet"
className="grid grid-cols-1 gap-3 sm:grid-cols-3"
>
{VISIBILITY_OPTIONS.map((o) => {
const selected = draft.visibility === o.value
const Icon = o.icon
return (
<label
key={o.value}
className={cn(
"flex cursor-pointer flex-col gap-1.5 rounded-xl border p-4 transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
selected
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-border bg-background hover:border-primary/50 hover:bg-accent/40",
)}
>
<span className="flex items-center gap-2">
<input
type="radio"
name="tsc-visibility"
value={o.value}
checked={selected}
onChange={() => patch("visibility", o.value)}
className="size-5 shrink-0 accent-primary"
/>
<Icon
aria-hidden="true"
className={cn(
"size-4 shrink-0",
selected ? "text-primary" : "text-muted-foreground",
)}
/>
<span className="text-base font-bold text-foreground">{o.label}</span>
</span>
<span className="pl-7 text-sm leading-relaxed text-muted-foreground text-pretty">
{o.description}
</span>
</label>
)
})}
</div>
</fieldset>
{/* Beskrivelse — spans full width */}
<div className="flex flex-col gap-2 lg:col-span-2">
<Label htmlFor="tsc-desc" className="text-base font-semibold text-foreground">
Beskrivelse
</Label>
<textarea
id="tsc-desc"
value={draft.description}
onChange={(e) => patch("description", e.target.value)}
rows={4}
className="w-full rounded-xl border border-border bg-background px-3 py-2.5 text-base leading-relaxed text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
placeholder="Valgfri informasjon til deltakerne."
/>
</div>
{/* Påmelding — grouped inset panel, spans full width */}
<fieldset className="rounded-2xl border border-border bg-background/60 p-4 lg:col-span-2 lg:p-5">
<legend className="flex items-center gap-2 rounded-lg bg-card px-2 text-base font-bold text-foreground">
Påmelding
</legend>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Påmeldingsfrist */}
<div className="flex flex-col gap-2">
<Label htmlFor="tsc-deadline" className="text-base font-semibold text-foreground">
Påmeldingsfrist
</Label>
<input
id="tsc-deadline"
type="datetime-local"
value={draft.registrationDeadline ?? ""}
onChange={(e) => patch("registrationDeadline", e.target.value || null)}
className={inputBase}
/>
<p className="text-sm text-muted-foreground">Valgfri. Tom = ingen frist.</p>
</div>
{/* Maks antall påmeldte */}
<div className="flex flex-col gap-2">
<Label htmlFor="tsc-capacity" className="text-base font-semibold text-foreground">
Maks antall påmeldte
</Label>
<input
id="tsc-capacity"
type="number"
inputMode="numeric"
min={1}
step={1}
value={draft.registrationCapacity ?? ""}
onChange={(e) => {
const v = e.target.value
if (v === "") return patch("registrationCapacity", null)
const n = Math.max(1, Math.floor(Number(v)))
patch("registrationCapacity", Number.isFinite(n) ? n : null)
}}
className={inputBase}
placeholder="Ubegrenset"
/>
<p className="text-sm text-muted-foreground">Tom = ubegrenset.</p>
</div>
{/* Når fullt — disabled + explained when no capacity */}
<fieldset
className="flex flex-col gap-3"
disabled={!capacitySet}
aria-describedby={!capacitySet ? "tsc-overflow-hint" : undefined}
>
<legend
className={cn(
"text-base font-semibold",
capacitySet ? "text-foreground" : "text-muted-foreground",
)}
>
Når fullt
</legend>
<div
role="radiogroup"
aria-label="Når fullt"
className={cn("grid grid-cols-2 gap-2", !capacitySet && "opacity-50")}
>
{(
[
{ value: "waitlist", label: "Venteliste" },
{ value: "closed", label: "Stengt" },
] as const
).map((o) => {
const selected = draft.registrationOverflowPolicy === o.value
return (
<label
key={o.value}
className={cn(
"flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-xl border px-3 text-base font-bold transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
!capacitySet && "cursor-not-allowed",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:border-primary/50 hover:bg-accent/40",
)}
>
<input
type="radio"
name="tsc-overflow"
value={o.value}
checked={selected}
disabled={!capacitySet}
onChange={() => patch("registrationOverflowPolicy", o.value)}
className="sr-only"
/>
{o.label}
</label>
)
})}
</div>
{!capacitySet && (
<p
id="tsc-overflow-hint"
className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground"
>
<TriangleAlert aria-hidden="true" className="size-4 shrink-0" />
Sett et maks-antall for å bruke dette.
</p>
)}
</fieldset>
{/* Krev godkjenning */}
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">Krev godkjenning</span>
<label
htmlFor="tsc-approval"
className="flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded-xl border border-border bg-card px-4 py-2"
>
<span className="text-sm leading-relaxed text-muted-foreground text-pretty">
{draft.registrationRequiresApproval
? "Organisator må godkjenne hver påmelding manuelt."
: "Påmeldinger godkjennes automatisk."}
</span>
<Switch
id="tsc-approval"
checked={draft.registrationRequiresApproval}
onCheckedChange={(checked) =>
patch("registrationRequiresApproval", checked === true)
}
aria-label="Krev godkjenning av påmeldinger"
/>
</label>
</div>
</div>
</fieldset>
</div>
{/* Save row */}
<div className="mt-7 flex flex-wrap items-center gap-3 border-t border-border pt-5">
<Button
type="button"
onClick={handleSave}
disabled={!canSave}
className="h-11 rounded-xl px-6 text-base font-bold"
>
{saving && <Loader2 aria-hidden="true" className="size-4 animate-spin" />}
{saving ? "Lagrer…" : "Lagre"}
</Button>
{status === "saved" && (
<span
role="status"
className="flex animate-in fade-in items-center gap-1.5 text-sm font-bold text-primary duration-300"
>
<Check aria-hidden="true" className="size-4" />
Lagret
</span>
)}
{status === "error" && errorMessage && (
<span role="alert" className="flex items-center gap-1.5 text-sm font-bold text-destructive">
<TriangleAlert aria-hidden="true" className="size-4" />
{errorMessage}
</span>
)}
{!dirty && status === "idle" && (
<span className="text-sm text-muted-foreground">Ingen ulagrede endringer.</span>
)}
</div>
</section>
)
}