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:
parent
18bfe555a0
commit
c5ab6a18f2
3 changed files with 572 additions and 0 deletions
43
CHANGELOG.md
43
CHANGELOG.md
|
|
@ -12768,3 +12768,46 @@ Neste steg:
|
|||
|
||||
**Rullet ut:** venter på bekreftelse (ren backend-tillegg, ingen
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { Input } from "@/components/ui/input"
|
|||
import { Label } from "@/components/ui/label"
|
||||
import { PlayerImportPanel } from "@/components/player-import-panel"
|
||||
import { RoundGroupsPanel } from "@/components/round-groups-panel"
|
||||
import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -103,10 +104,22 @@ type NewPlayerInput = {
|
|||
|
||||
type ApiTournamentInfo = {
|
||||
id: string
|
||||
name: string
|
||||
status: TournamentStatus
|
||||
join_code: string
|
||||
format_type: string
|
||||
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).
|
||||
bbb_sweep_bonus_enabled: boolean
|
||||
// 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
|
||||
// automatisk ved fullspilt runde) -- idempotent på serveren, men likevel
|
||||
// en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra
|
||||
|
|
@ -904,6 +956,7 @@ export function IndividualTournamentDetail({
|
|||
onUpdateFlagMapVisible={updateFlagMapVisible}
|
||||
onUpdateCutConfig={updateCutConfig}
|
||||
onUpdateTiebreakConfig={updateTiebreakConfig}
|
||||
onUpdateSettings={updateSettings}
|
||||
onApplyCut={applyCut}
|
||||
onAddParticipant={addParticipant}
|
||||
onAddNewPlayer={addNewPlayerAndParticipant}
|
||||
|
|
@ -967,6 +1020,26 @@ function JoinCodeChip({ code }: { code: string }) {
|
|||
// 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({
|
||||
base,
|
||||
organizationId,
|
||||
|
|
@ -981,6 +1054,7 @@ function SetupTab({
|
|||
onUpdateCutConfig,
|
||||
onApplyCut,
|
||||
onUpdateTiebreakConfig,
|
||||
onUpdateSettings,
|
||||
onAddParticipant,
|
||||
onAddNewPlayer,
|
||||
onImportComplete,
|
||||
|
|
@ -1008,6 +1082,7 @@ function SetupTab({
|
|||
onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void>
|
||||
onApplyCut: () => Promise<void>
|
||||
onUpdateTiebreakConfig: (winner: string, field: string) => Promise<void>
|
||||
onUpdateSettings: (next: TournamentSettingsValues) => Promise<void>
|
||||
onAddParticipant: (playerId: string) => Promise<void>
|
||||
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
||||
onImportComplete: () => Promise<void>
|
||||
|
|
@ -1042,8 +1117,24 @@ function SetupTab({
|
|||
}
|
||||
}, [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 (
|
||||
<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">
|
||||
<h2 className="text-base font-bold text-foreground">Scoringsmetode</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
|
|
|
|||
438
frontend/components/tournament-settings-card.tsx
Normal file
438
frontend/components/tournament-settings-card.tsx
Normal 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"
|
||||
>
|
||||
–
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue