diff --git a/CHANGELOG.md b/CHANGELOG.md
index 238af9b..0d9fc45 100644
--- a/CHANGELOG.md
+++ b/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): `` 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).
diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx
index f4807be..47ccfa2 100644
--- a/frontend/components/individual-tournament-detail.tsx
+++ b/frontend/components/individual-tournament-detail.tsx
@@ -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
// =========================================================================
+// 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
onApplyCut: () => Promise
onUpdateTiebreakConfig: (winner: string, field: string) => Promise
+ onUpdateSettings: (next: TournamentSettingsValues) => Promise
onAddParticipant: (playerId: string) => Promise
onAddNewPlayer: (input: NewPlayerInput) => Promise
onImportComplete: () => Promise
@@ -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 (
+
+
Scoringsmetode
diff --git a/frontend/components/tournament-settings-card.tsx b/frontend/components/tournament-settings-card.tsx
new file mode 100644
index 0000000..5e9139d
--- /dev/null
+++ b/frontend/components/tournament-settings-card.tsx
@@ -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
+}) {
+ const [draft, setDraft] = useState(values)
+ const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
+ const [errorMessage, setErrorMessage] = useState(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(JSON.stringify(values))
+ useEffect(() => {
+ const serialized = JSON.stringify(values)
+ if (serialized !== lastSyncedRef.current) {
+ lastSyncedRef.current = serialized
+ setDraft(values)
+ }
+ }, [values])
+
+ const savedFadeRef = useRef | null>(null)
+ useEffect(() => {
+ return () => {
+ if (savedFadeRef.current) clearTimeout(savedFadeRef.current)
+ }
+ }, [])
+
+ function patch(
+ 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 (
+
+
+
+ Grunnleggende
+
+
+ Navn, datoer, synlighet og påmelding for turneringen.
+
+
+ {/* Datoer — one connected range control */}
+
+
+ {/* Synlighet — real radio group, spans full width */}
+
+
+ {/* Beskrivelse — spans full width */}
+