feat(event-feature): enhance Veranstaltungs- & Turnier-Workflow
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Waiting to run

- Extended `Veranstaltung` domain model with new fields: `untertitel`, `logoUrl`, and `sponsoren`.
- Refined navigation in `DesktopMainLayout.kt` to check turnier context and improve routing.
- Overhauled `TurnierStammdatenTab` with enhanced interactivity: dynamic chip-based selectors for Spartens, Klassen, and Sponsors, as well as date pickers and ZNS import handling.
- Implemented validations for date ranges and required fields.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-31 17:33:07 +02:00
parent 496e801943
commit f44b2c8126
13 changed files with 1199 additions and 257 deletions
@@ -40,7 +40,10 @@ data class Veranstaltung(
// Basic Information // Basic Information
var name: String, var name: String,
var untertitel: String? = null,
var beschreibung: String? = null, var beschreibung: String? = null,
var logoUrl: String? = null,
var sponsoren: String? = null, // JSON string or comma-separated for now
// Dates // Dates
@Serializable(with = KotlinLocalDateSerializer::class) @Serializable(with = KotlinLocalDateSerializer::class)
@@ -153,7 +153,10 @@ class VeranstaltungRepositoryImpl : VeranstaltungRepository {
return Veranstaltung( return Veranstaltung(
veranstaltungId = row[VeranstaltungTable.id].value, veranstaltungId = row[VeranstaltungTable.id].value,
name = row[VeranstaltungTable.name], name = row[VeranstaltungTable.name],
untertitel = row[VeranstaltungTable.untertitel],
beschreibung = row[VeranstaltungTable.beschreibung], beschreibung = row[VeranstaltungTable.beschreibung],
logoUrl = row[VeranstaltungTable.logoUrl],
sponsoren = row[VeranstaltungTable.sponsoren],
startDatum = row[VeranstaltungTable.startDatum], startDatum = row[VeranstaltungTable.startDatum],
endDatum = row[VeranstaltungTable.endDatum], endDatum = row[VeranstaltungTable.endDatum],
ort = row[VeranstaltungTable.ort], ort = row[VeranstaltungTable.ort],
@@ -173,7 +176,10 @@ class VeranstaltungRepositoryImpl : VeranstaltungRepository {
*/ */
private fun veranstaltungToStatement(statement: UpdateBuilder<*>, veranstaltung: Veranstaltung) { private fun veranstaltungToStatement(statement: UpdateBuilder<*>, veranstaltung: Veranstaltung) {
statement[VeranstaltungTable.name] = veranstaltung.name statement[VeranstaltungTable.name] = veranstaltung.name
statement[VeranstaltungTable.untertitel] = veranstaltung.untertitel
statement[VeranstaltungTable.beschreibung] = veranstaltung.beschreibung statement[VeranstaltungTable.beschreibung] = veranstaltung.beschreibung
statement[VeranstaltungTable.logoUrl] = veranstaltung.logoUrl
statement[VeranstaltungTable.sponsoren] = veranstaltung.sponsoren
statement[VeranstaltungTable.startDatum] = veranstaltung.startDatum statement[VeranstaltungTable.startDatum] = veranstaltung.startDatum
statement[VeranstaltungTable.endDatum] = veranstaltung.endDatum statement[VeranstaltungTable.endDatum] = veranstaltung.endDatum
statement[VeranstaltungTable.ort] = veranstaltung.ort statement[VeranstaltungTable.ort] = veranstaltung.ort
@@ -16,7 +16,10 @@ object VeranstaltungTable : UUIDTable("veranstaltungen") {
// Basic Information // Basic Information
val name = varchar("name", 255) val name = varchar("name", 255)
val untertitel = varchar("untertitel", 255).nullable()
val beschreibung = text("beschreibung").nullable() val beschreibung = text("beschreibung").nullable()
val logoUrl = varchar("logo_url", 255).nullable()
val sponsoren = text("sponsoren").nullable() // JSON array of Sponsor data
// Dates // Dates
val startDatum = date("start_datum") val startDatum = date("start_datum")
@@ -67,6 +67,7 @@ data class DomVerein(
// Status & Verwaltung // Status & Verwaltung
var istAktiv: Boolean = true, var istAktiv: Boolean = true,
var logoUrl: String? = null,
var bemerkungen: String? = null, var bemerkungen: String? = null,
var datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS, var datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS,
@@ -34,6 +34,7 @@ class ExposedVereinRepository : VereinRepository {
oepsRegionNummer = row[VereinTable.oepsRegionNummer], oepsRegionNummer = row[VereinTable.oepsRegionNummer],
istVeranstalter = row[VereinTable.istVeranstalter], istVeranstalter = row[VereinTable.istVeranstalter],
istAktiv = row[VereinTable.istAktiv], istAktiv = row[VereinTable.istAktiv],
logoUrl = row[VereinTable.logoUrl],
bemerkungen = row[VereinTable.bemerkungen], bemerkungen = row[VereinTable.bemerkungen],
datenQuelle = DatenQuelleE.valueOf(row[VereinTable.datenQuelle]), datenQuelle = DatenQuelleE.valueOf(row[VereinTable.datenQuelle]),
createdAt = row[VereinTable.createdAt], createdAt = row[VereinTable.createdAt],
@@ -106,6 +107,7 @@ class ExposedVereinRepository : VereinRepository {
it[oepsRegionNummer] = verein.oepsRegionNummer it[oepsRegionNummer] = verein.oepsRegionNummer
it[istVeranstalter] = verein.istVeranstalter it[istVeranstalter] = verein.istVeranstalter
it[istAktiv] = verein.istAktiv it[istAktiv] = verein.istAktiv
it[logoUrl] = verein.logoUrl
it[bemerkungen] = verein.bemerkungen it[bemerkungen] = verein.bemerkungen
it[datenQuelle] = verein.datenQuelle.name it[datenQuelle] = verein.datenQuelle.name
it[updatedAt] = verein.updatedAt it[updatedAt] = verein.updatedAt
@@ -127,6 +129,7 @@ class ExposedVereinRepository : VereinRepository {
it[oepsRegionNummer] = verein.oepsRegionNummer it[oepsRegionNummer] = verein.oepsRegionNummer
it[istVeranstalter] = verein.istVeranstalter it[istVeranstalter] = verein.istVeranstalter
it[istAktiv] = verein.istAktiv it[istAktiv] = verein.istAktiv
it[logoUrl] = verein.logoUrl
it[bemerkungen] = verein.bemerkungen it[bemerkungen] = verein.bemerkungen
it[datenQuelle] = verein.datenQuelle.name it[datenQuelle] = verein.datenQuelle.name
it[createdAt] = verein.createdAt it[createdAt] = verein.createdAt
@@ -169,6 +172,7 @@ class ExposedVereinRepository : VereinRepository {
it[oepsRegionNummer] = toUpdate.oepsRegionNummer it[oepsRegionNummer] = toUpdate.oepsRegionNummer
it[istVeranstalter] = toUpdate.istVeranstalter it[istVeranstalter] = toUpdate.istVeranstalter
it[istAktiv] = toUpdate.istAktiv it[istAktiv] = toUpdate.istAktiv
it[logoUrl] = toUpdate.logoUrl
it[bemerkungen] = toUpdate.bemerkungen it[bemerkungen] = toUpdate.bemerkungen
it[datenQuelle] = toUpdate.datenQuelle.name it[datenQuelle] = toUpdate.datenQuelle.name
it[updatedAt] = toUpdate.updatedAt it[updatedAt] = toUpdate.updatedAt
@@ -0,0 +1,22 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.infrastructure.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.datetime.CurrentTimestamp
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Verknüpfungstabelle zwischen Verein und Reiter (Ansprechpersonen/Funktionäre).
*/
object VereinAnsprechpersonTable : Table("verein_ansprechperson") {
val vereinId = uuid("verein_id").references(VereinTable.id)
val reiterId = uuid("reiter_id").references(ReiterTable.id)
val rolle = varchar("rolle", 100).nullable() // z.B. "Obmann", "Meldestelle", "Sportwart"
val istHauptkontakt = bool("ist_hauptkontakt").default(false)
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp)
override val primaryKey = PrimaryKey(vereinId, reiterId)
}
@@ -24,6 +24,7 @@ object VereinTable : Table("verein") {
val oepsRegionNummer = varchar("oeps_region_nummer", 10).nullable() val oepsRegionNummer = varchar("oeps_region_nummer", 10).nullable()
val istVeranstalter = bool("ist_veranstalter").default(false) val istVeranstalter = bool("ist_veranstalter").default(false)
val istAktiv = bool("ist_aktiv").default(true) val istAktiv = bool("ist_aktiv").default(true)
val logoUrl = varchar("logo_url", 255).nullable()
val bemerkungen = text("bemerkungen").nullable() val bemerkungen = text("bemerkungen").nullable()
val datenQuelle = varchar("daten_quelle", 50) val datenQuelle = varchar("daten_quelle", 50)
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
@@ -1,3 +1,108 @@
## Nachtrag 31.03.2026 17:35
- **Validierung & Konsistenz im Turnier-Workflow:**
- **Veranstaltung anlegen:** In `VeranstaltungKonfigV2` wurde eine Sperre für Vergangenheits-Daten implementiert. Das
Startdatum darf nicht vor dem aktuellen Tag liegen. Entsprechende UI-Fehlermeldungen und eine Button-Deaktivierung
wurden hinzugefügt.
- **Turnier-Stammdaten (Bearbeiten):** Der Tab "STAMMDATEN" im `TurnierDetailScreen` wurde vollständig überarbeitet
und spiegelt nun die Logik des `TurnierWizardV2` (Option 1) wider.
- **Validierung:** 5-stellige Turnier-Nr. muss explizit bestätigt werden.
- **ZNS-Import:** Statusanzeige (geladen/nicht geladen) und interaktive Import-Buttons (Internet/USB) wurden
integriert.
- **Regelwerk:** Dynamische Generierung von Kategorien (inkl. Pony-Kategorien) basierend auf Sparten- und
Klassenauswahl via Filter-Chips.
- **Datum & Ort:** Integration von Material 3 DatePickern. Hinweise auf die erforderliche Übereinstimmung mit dem
Veranstaltungszeitraum und -ort wurden hinzugefügt.
- **Branding:** Unterstützung für Titel, Sub-Titel und dynamische Sponsoren-Chips direkt im Stammdaten-Tab.
- **UI/UX:** Einsatz von `FlowRow`, `InputChip` und `SectionCard` für ein aufgeräumtes, konsistentes Erscheinungsbild
über alle Turnier-Screens hinweg.
## Nachtrag 31.03.2026 17:15
- **`TurnierWizardV2` - Regelwerks-Kategorien & Pony-Logik:**
- **Refactoring Kategorien:** Turniere unterstützen nun mehrere gleichberechtigte Kategorien (z.B. "CDN-C NEU" und "
CDNP-C NEU") gleichzeitig. Dies ist entscheidend für die korrekte Anwendung der Regelwerke (z.B. Nationales
Dressur-Turnier vs. Nationales Dressur-Turnier Pony).
- **Integration Pony-Status:** Der separate Switch für "Pony-Bewerbe" wurde entfernt. Stattdessen werden
Pony-Kategorien (Suffix "P") nun direkt als auswählbare Optionen in den Kategorien-Vorschlägen angeboten, sofern
eine Sparte und Klasse gewählt wurde.
- **Datenmodell `TurnierV2`:** Das Feld `isPony` wurde entfernt, da der Status nun implizit über die gewählten
Kategorien definiert ist.
- **Automatisierung:** Bei Eingabe der Turnier-Nummern für Neumarkt (26128, 26129) werden nun automatisch sowohl die
Standard- als auch die Pony-Kategorie vorselektiert.
- **Seed-Daten:** Die Testdaten in `Stores.kt` wurden aktualisiert, um Turniere mit mehreren Kategorien (CDN + CDNP)
abzubilden.
## Nachtrag 31.03.2026 17:10
- **`TurnierWizardV2` - Klassen & Pony-Bewerbe:**
- **Klassen-Auswahl:** Umstellung auf ein modernes Chip-basiertes Layout (Grid). Die Klassen (C-NEU bis S) werden nun
als `FilterChip` dargestellt, was die Mehrfachauswahl intuitiver macht.
- **Pony-Bewerbe:** Integration einer neuen "Pony-Bewerbe" Option (Switch) in Schritt 2. Diese Option steuert die
sportfachliche Kennzeichnung des Turniers.
- **Kategorien-Logik (CDNP/CSNP):** Die automatische Generierung der Kategorien-Vorschläge berücksichtigt nun den
Pony-Status. Bei aktiviertem Switch wird automatisch das Suffix "P" (z.B. CDNP statt CDN) verwendet.
- **UI/UX Refinement:**
- Einsatz von `InputChip` für die Kategorien-Auswahl mit Checkmark-Indikator.
- Gruppierung der Optionen (Sparten, Pony, Klassen) in einer übersichtlichen Spalten/Zeilen-Struktur mit
verbesserten Abständen.
- Manuelle Korrekturmöglichkeit der Kategorie im `OutlinedTextField` mit `leadingIcon`.
- **Datenmodell & Seed:** Erweiterung von `TurnierV2` um das Feld `isPony` und Aktualisierung der Seed-Daten für "
Neumarkt 2026" auf den neuen Pony-Status.
## Nachtrag 31.03.2026 16:45
- **`TurnierWizardV2` - ZNS-Import & Regelwerks-Logik:**
- **Schritt 1 (Basics):** Überarbeitung der Turnier-Nr. Erfassung mit explizitem Bestätigungs-Button und Validierung (
5 Stellen).
- **ZNS-Import:** Implementierung von zwei Import-Wegen (Internet / USB). Ein interaktiver Fortschritts-Dialog
simuliert die Datenverarbeitung und setzt den `znsDataLoaded`-Status.
- **ZNS-Statusanzeige:** Ein markantes Status-Panel (Grün/Rot) zeigt an, ob die Pflicht-Stammdaten geladen wurden.
Der "Weiter"-Button ist erst nach erfolgreichem Import aktiv.
- **Schritt 2 (Sparten & Klassen):** Erweiterung der Klassen-Auswahl (C-NEU bis S) in einem übersichtlichen
Spalten-Layout.
- **Intelligente Kategorien-Vorschläge:** Implementierung einer Logik, die basierend auf den gewählten Sparten und
Klassen passende Turnier-Kategorien (z.B. CSN-C-NEU, CDN-A) als Filter-Chips vorschlägt.
- **Modell-Update:** `TurnierV2` um `znsDataLoaded` erweitert und die Sprach-Auswahl gemäß Benutzerwunsch entfernt.
## Nachtrag 31.03.2026 16:30
- **`TurnierWizardV2` - "Meta"-Daten & Stammdaten:**
- Der Wizard zur Neuanlage eines Turniers wurde gemäß den Benutzervorgaben (Screenshots `Turnier-Stammdaten_01/02`)
umfassend erweitert und in drei Phasen unterteilt.
- **Schritt 1: Basiskonfiguration:** Erfassung der 5-stelligen Turnier-Nr., des Typs (ÖTO National / FEI
International), Sprache (Deutsch/Englisch) und Integration von Platzhalter-Buttons für den ZNS-Daten-Import (
Internet/USB) inkl. Informations-Dialog.
- **Schritt 2: Sparten & Klassen:** Auswahl der Disziplinen (Dressur, Springen) und Klassen (C, B, A). Die Kategorien
werden basierend auf der Auswahl freigeschaltet und bei bekannten Nummern (z.B. 26128) automatisch vorbelegt.
- **Schritt 3: Branding & Sponsoren:** Erfassung von Turnier-Titel, Sub-Titel und einer dynamisch erweiterbaren
Sponsorenliste (analog zum Veranstaltungs-Wizard).
- **Datenmodell `TurnierV2`:** Erweiterung um alle neuen Felder (`typ`, `sprache`, `sparten`, `klassen`, `titel`,
`subTitel`, `sponsoren`) unter Nutzung von `SnapshotStateList` für reaktive UI-Updates.
- **UI/UX Polish:** Nutzung von `LinearProgressIndicator`, `RadioButton`-Gruppen, `Checkbox`-Listen und
`verticalScroll` für eine flüssige Bedienung auf kleineren Bildschirmen.
## Nachtrag 31.03.2026 16:15
- **Event-Cockpit-Optimierung:**
- **`VeranstaltungUebersichtV2`:** Umfassendes UI-Update für das Veranstaltungs-Cockpit.
- **KPI-Dashboard:** Integration von vier KPI-Karten (Turniere, Nennungen, Reiter, Pferde) für eine schnelle Übersicht
des Event-Status.
- **Turnier-Liste:** Umstellung auf ein modernes Card-Layout mit `OutlinedCard`, `SuggestionChip` für Kategorien und
verbesserten Action-Buttons (Öffnen/Löschen).
- **Turnier-Wizard:** Die Validierung der 5-stelligen Turnier-Nummer wurde durch `supportingText` im Textfeld
verbessert.
- **Navigation & Routing:**
- **`DesktopMainLayout.kt`:** Die Navigation für `AppScreen.TurnierDetail` und `AppScreen.TurnierNeu` wurde
vollständig auf den `v2`-Store und die neuen Screens (`VeranstaltungUebersichtV2`, `TurnierWizardV2`) umgestellt.
- **Back-Navigation:** "Zurück"-Buttons in den Turnier-Screens führen nun logisch zurück zur
`VeranstaltungUebersichtV2` anstatt zu veralteten Screens.
- **Demonstrations-Daten:**
- Für das Beispiel-Event "Neumarkt 2026" (ID 100) wurden realistische Platzhalter-Werte in die KPIs integriert (z.B.
248 Nennungen), um das finale Look-and-Feel zu demonstrieren.
## Nachtrag 31.03.2026 15:45 ## Nachtrag 31.03.2026 15:45
- **Fehlerbehebung Desktop-Shell Build:** - **Fehlerbehebung Desktop-Shell Build:**
@@ -1,6 +1,8 @@
package at.mocode.turnier.feature.presentation package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -34,6 +36,11 @@ fun TurnierDetailScreen(
) { ) {
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
// Temporäre Lösung bis zur echten Repository-Anbindung:
// Da TurnierDetailScreen in einem anderen Modul liegt, übergeben wir
// die Veranstaltungsinformationen eigentlich via ViewModel.
// Hier nutzen wir vorerst koin oder Parameter.
val tabs = listOf( val tabs = listOf(
"STAMMDATEN", "STAMMDATEN",
"ORGANISATION", "ORGANISATION",
@@ -1,11 +1,11 @@
package at.mocode.turnier.feature.presentation package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import java.time.LocalDate
private val PrimaryBlue = Color(0xFF1E3A8A) private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6) private val AccentBlue = Color(0xFF3B82F6)
@@ -26,214 +27,328 @@ private val AccentBlue = Color(0xFF3B82F6)
* - Turnier-Beschreibung: Titel, Sub-Titel * - Turnier-Beschreibung: Titel, Sub-Titel
* - Sponsoren * - Sponsoren
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun StammdatenTabContent(turnierId: Long) { fun StammdatenTabContent(turnierId: Long) {
// In einer echten App würden wir diese Daten aus einem ViewModel laden.
// Hier simulieren wir den State basierend auf den Anforderungen.
var turnierNr by remember { mutableStateOf("") } var turnierNr by remember { mutableStateOf("") }
var typOto by remember { mutableStateOf(true) } var nrConfirmed by remember { mutableStateOf(false) }
var spracheDe by remember { mutableStateOf(true) } var znsDataLoaded by remember { mutableStateOf(false) }
var sparteDressur by remember { mutableStateOf(false) } var typ by remember { mutableStateOf("ÖTO (National)") }
var sparteSpringen by remember { mutableStateOf(false) }
var klasseC by remember { mutableStateOf(false) } val sparten = remember { mutableStateListOf<String>() }
var klasseB by remember { mutableStateOf(false) } val klassen = remember { mutableStateListOf<String>() }
var klasseA by remember { mutableStateOf(false) } val kat = remember { mutableStateListOf<String>() }
var datumVon by remember { mutableStateOf("") }
var datumBis by remember { mutableStateOf("") } var von by remember { mutableStateOf("") }
var bis by remember { mutableStateOf("") }
var ort by remember { mutableStateOf("") }
var titel by remember { mutableStateOf("") } var titel by remember { mutableStateOf("") }
var subTitel by remember { mutableStateOf("") } var subTitel by remember { mutableStateOf("") }
val sponsoren = remember { mutableStateListOf<String>() }
var showZnsDialog by remember { mutableStateOf(false) }
// Hilfs-States für DatePicker
var showDatePickerVon by remember { mutableStateOf(false) }
var showDatePickerBis by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(scrollState)
.padding(24.dp), .padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
// ── Turnier-Konfiguration ──────────────────────────────────────────── // ── Turnier-Konfiguration (Schritt 1 Logik) ───────────────────────────
SectionCard(title = "Turnier-Konfiguration") { SectionCard(title = "Turnier-Konfiguration & ZNS") {
FormRow("Turnier-Nr.:") { FormRow("Turnier-Nr.:") {
OutlinedTextField( Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
value = turnierNr, OutlinedTextField(
onValueChange = { turnierNr = it }, value = turnierNr,
placeholder = { Text("z.B. 26128", fontSize = 13.sp) }, onValueChange = { if (it.length <= 5 && it.all { c -> c.isDigit() }) turnierNr = it },
modifier = Modifier.width(200.dp).height(48.dp), placeholder = { Text("5-stellig", fontSize = 13.sp) },
singleLine = true, modifier = Modifier.width(120.dp),
) singleLine = true,
enabled = !nrConfirmed
)
if (!nrConfirmed) {
Button(
onClick = { nrConfirmed = true },
enabled = turnierNr.length == 5,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
) {
Text("Bestätigen")
}
} else {
InputChip(
selected = true,
onClick = { nrConfirmed = false },
label = { Text("Bestätigt") },
trailingIcon = { Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(16.dp)) }
)
}
}
if (turnierNr.length == 5 && !nrConfirmed) {
Text(
"Bitte Turnier-Nummer bestätigen um fortzufahren.",
color = MaterialTheme.colorScheme.error,
fontSize = 11.sp
)
}
} }
FormRow("Typ:") { FormRow("Typ:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = typOto, onClick = { typOto = true }) FilterChip(
Text("OTO (National)", fontSize = 13.sp) selected = typ == "ÖTO (National)",
RadioButton(selected = !typOto, onClick = { typOto = false }) onClick = { typ = "ÖTO (National)" },
Text("FEI (International)", fontSize = 13.sp) label = { Text("ÖTO (National)") }
)
FilterChip(
selected = typ == "FEI (International)",
onClick = { typ = "FEI (International)" },
label = { Text("FEI (International)") }
)
} }
} }
FormRow("ZNS-Daten:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FormRow("ZNS-Stammdaten:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Button( Button(
onClick = {}, onClick = { showZnsDialog = true },
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue), colors = ButtonDefaults.buttonColors(containerColor = AccentBlue)
) { ) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(16.dp)) Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(8.dp))
Text("Import via Internet", fontSize = 13.sp) Text("Import via Internet")
} }
OutlinedButton(onClick = {}) { OutlinedButton(onClick = { showZnsDialog = true }) {
Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(8.dp))
Text("Import via USB", fontSize = 13.sp) Text("Import via USB")
} }
} }
Text(
"Reiter-, Pferde-, Funktionärs- und Vereinsdaten vom OEPS Backend", val znsStatusColor = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
fontSize = 11.sp, Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
color = Color(0xFF6B7280), Icon(
) if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Error,
} contentDescription = null,
FormRow("Sprache:") { tint = znsStatusColor,
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { modifier = Modifier.size(16.dp)
RadioButton(selected = spracheDe, onClick = { spracheDe = true }) )
Text("Deutsch", fontSize = 13.sp) Text(
RadioButton(selected = !spracheDe, onClick = { spracheDe = false }) if (znsDataLoaded) "ZNS-Daten geladen" else "Keine ZNS-Daten geladen",
Text("English", fontSize = 13.sp) color = znsStatusColor,
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
} }
} }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) }
FormRow("Sparten:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { // ── Sparten & Kategorien (Schritt 2 Logik) ───────────────────────────
Checkbox(checked = sparteDressur, onCheckedChange = { sparteDressur = it }) SectionCard(title = "Reglement & Sparten") {
Text("Dressur", fontSize = 13.sp) FormRow("Sparte:") {
Checkbox(checked = sparteSpringen, onCheckedChange = { sparteSpringen = it }) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Springen", fontSize = 13.sp) FilterChip(
selected = sparten.contains("Dressur"),
onClick = { if (sparten.contains("Dressur")) sparten.remove("Dressur") else sparten.add("Dressur") },
label = { Text("Dressur") }
)
FilterChip(
selected = sparten.contains("Springen"),
onClick = { if (sparten.contains("Springen")) sparten.remove("Springen") else sparten.add("Springen") },
label = { Text("Springen") }
)
} }
} }
FormRow("Klassen:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { FormRow("Klasse:") {
Checkbox(checked = klasseC, onCheckedChange = { klasseC = it }) val klassenListe = listOf("C-NEU", "C", "B", "A", "L", "LM", "M", "S")
Text("C", fontSize = 13.sp) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Checkbox(checked = klasseB, onCheckedChange = { klasseB = it }) klassenListe.forEach { k ->
Text("B", fontSize = 13.sp) FilterChip(
Checkbox(checked = klasseA, onCheckedChange = { klasseA = it }) selected = klassen.contains(k),
Text("A", fontSize = 13.sp) onClick = { if (klassen.contains(k)) klassen.remove(k) else klassen.add(k) },
} label = { Text(k) }
}
FormRow("Kategorien:") {
Surface(
modifier = Modifier.fillMaxWidth().height(60.dp),
color = Color(0xFFF3F4F6),
shape = MaterialTheme.shapes.small,
) {
Box(contentAlignment = Alignment.Center) {
Text(
"Bitte Sparte(n) auswählen",
fontSize = 13.sp,
color = Color(0xFF9CA3AF),
) )
} }
} }
} }
FormRow("Datum:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { FormRow("Kategorien:") {
// Logik zur Generierung der Kategorien
val suggested = mutableListOf<String>()
sparten.forEach { s ->
val prefix = if (s == "Dressur") "CDN" else "CSN"
klassen.forEach { k ->
suggested.add("$prefix-$k")
suggested.add("${prefix}P-$k") // Pony Variante
}
}
if (suggested.isEmpty()) {
Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp)
} else {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
suggested.forEach { c ->
InputChip(
selected = kat.contains(c),
onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) },
label = { Text(c) }
)
}
}
}
}
FormRow("Zeitraum:") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField( OutlinedTextField(
value = datumVon, value = von,
onValueChange = { datumVon = it }, onValueChange = {},
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) }, label = { Text("Von") },
modifier = Modifier.width(160.dp).height(48.dp), modifier = Modifier.width(160.dp).clickable { showDatePickerVon = true },
singleLine = true, readOnly = true,
trailingIcon = { Icon(Icons.Default.DateRange, null) }
) )
Text("bis", fontSize = 13.sp) Text("bis")
OutlinedTextField( OutlinedTextField(
value = datumBis, value = bis,
onValueChange = { datumBis = it }, onValueChange = {},
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) }, label = { Text("Bis") },
modifier = Modifier.width(160.dp).height(48.dp), modifier = Modifier.width(160.dp).clickable { showDatePickerBis = true },
singleLine = true, readOnly = true,
trailingIcon = { Icon(Icons.Default.DateRange, null) }
) )
} }
Text("Hinweis: Muss innerhalb des Veranstaltungs-Zeitraums liegen.", fontSize = 11.sp, color = Color.Gray)
}
FormRow("Ort:") {
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Austragungsort") },
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Muss mit Veranstaltungsort übereinstimmen.") }
)
} }
} }
// ── Turnier-Beschreibung ───────────────────────────────────────────── // ── Branding (Schritt 3 Logik) ───────────────────────────────────────
SectionCard(title = "Turnier-Beschreibung") { SectionCard(title = "Turnier-Branding") {
OutlinedTextField( OutlinedTextField(
value = titel, value = titel,
onValueChange = { titel = it }, onValueChange = { titel = it },
placeholder = { Text("z.B. Frühjahrs-Turnier 2026", fontSize = 13.sp) },
label = { Text("Titel") }, label = { Text("Titel") },
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth()
singleLine = true,
) )
OutlinedTextField( OutlinedTextField(
value = subTitel, value = subTitel,
onValueChange = { subTitel = it }, onValueChange = { subTitel = it },
placeholder = { Text("z.B. KIDS CUP • PONY EINSTEIGER CUP OÖ", fontSize = 13.sp) },
label = { Text("Sub-Titel") }, label = { Text("Sub-Titel") },
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth()
singleLine = true,
) )
}
// ── Sponsoren ──────────────────────────────────────────────────────── FormRow("Sponsoren:") {
SectionCard( FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
title = "Sponsoren", sponsoren.forEach { s ->
action = { InputChip(
TextButton(onClick = {}) { selected = true,
Text("+ Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp) onClick = { sponsoren.remove(s) },
} label = { Text(s) },
}, trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(14.dp)) }
) { )
Surface( }
modifier = Modifier.fillMaxWidth().height(80.dp), TextButton(onClick = { sponsoren.add("Neuer Sponsor") }) {
color = Color(0xFFF9FAFB), Text("+ Hinzufügen")
shape = MaterialTheme.shapes.small,
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Noch keine Sponsoren hinzugefügt", fontSize = 13.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(4.dp))
TextButton(onClick = {}) {
Text("+ Ersten Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp)
}
} }
} }
} }
} }
// ── Aktions-Buttons ────────────────────────────────────────────────── // ── Footer ──────────────────────────────────────────────────────────
Row( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = {}) { Text("Zurücksetzen") }
Spacer(Modifier.width(8.dp))
Button( Button(
onClick = {}, onClick = { /* Speichern */ },
enabled = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank() && titel.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Speichern") } modifier = Modifier.padding(bottom = 24.dp)
) {
Icon(Icons.Default.Save, null)
Spacer(Modifier.width(8.dp))
Text("Änderungen speichern")
}
} }
} }
// Dialog-Simulationen
if (showZnsDialog) {
AlertDialog(
onDismissRequest = { showZnsDialog = false },
title = { Text("ZNS Import") },
text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") },
confirmButton = {
TextButton(onClick = { znsDataLoaded = true; showZnsDialog = false }) { Text("Importieren") }
},
dismissButton = {
TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") }
}
)
}
if (showDatePickerVon) {
val state = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = { showDatePickerVon = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let {
von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
}
showDatePickerVon = false
}) { Text("OK") }
}
) { DatePicker(state) }
}
if (showDatePickerBis) {
val state = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = { showDatePickerBis = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let {
bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
}
showDatePickerBis = false
}) { Text("OK") }
}
) { DatePicker(state) }
}
} }
@Composable @Composable
private fun SectionCard( private fun SectionCard(
title: String, title: String,
action: @Composable (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit
content: @Composable ColumnScope.() -> Unit,
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row( Text(title, style = MaterialTheme.typography.titleLarge, color = PrimaryBlue, fontWeight = FontWeight.Bold)
modifier = Modifier.fillMaxWidth(), HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
action?.invoke()
}
content() content()
} }
} }
@@ -241,20 +356,14 @@ private fun SectionCard(
@Composable @Composable
private fun FormRow(label: String, content: @Composable ColumnScope.() -> Unit) { private fun FormRow(label: String, content: @Composable ColumnScope.() -> Unit) {
Row( Row(Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
Text( Text(
text = label, label,
fontSize = 13.sp,
modifier = Modifier.width(140.dp).padding(top = 12.dp), modifier = Modifier.width(140.dp).padding(top = 12.dp),
color = Color(0xFF374151), style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
) )
Column( Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
content() content()
} }
} }
@@ -23,9 +23,7 @@ import at.mocode.frontend.features.verein.presentation.VereinViewModel
import at.mocode.ping.feature.presentation.PingScreen import at.mocode.ping.feature.presentation.PingScreen
import at.mocode.ping.feature.presentation.PingViewModel import at.mocode.ping.feature.presentation.PingViewModel
import at.mocode.turnier.feature.presentation.TurnierDetailScreen import at.mocode.turnier.feature.presentation.TurnierDetailScreen
import at.mocode.turnier.feature.presentation.TurnierWizardV2
import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore
import at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
@@ -388,7 +386,10 @@ private fun DesktopContentArea(
// Turnier-Screens // Turnier-Screens
is AppScreen.TurnierDetail -> { is AppScreen.TurnierDetail -> {
val evtId = currentScreen.veranstaltungId val evtId = currentScreen.veranstaltungId
if (!FakeVeranstaltungStore.exists(evtId)) { val parent = at.mocode.desktop.v2.StoreV2.vereine.firstOrNull { v ->
at.mocode.desktop.v2.StoreV2.eventsFor(v.id).any { it.id == evtId }
}
if (parent == null) {
InvalidContextNotice( InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) nicht gefunden.", message = "Veranstaltung (ID=$evtId) nicht gefunden.",
onBack = { onNavigate(AppScreen.Veranstaltungen) } onBack = { onNavigate(AppScreen.Veranstaltungen) }
@@ -397,7 +398,7 @@ private fun DesktopContentArea(
TurnierDetailScreen( TurnierDetailScreen(
veranstaltungId = evtId, veranstaltungId = evtId,
turnierId = currentScreen.turnierId, turnierId = currentScreen.turnierId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) }, onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
) )
} }
} }
@@ -413,10 +414,11 @@ private fun DesktopContentArea(
onBack = { onNavigate(AppScreen.Veranstaltungen) } onBack = { onNavigate(AppScreen.Veranstaltungen) }
) )
} else { } else {
TurnierWizardV2( at.mocode.desktop.v2.TurnierWizardV2(
veranstalterId = parent.id,
veranstaltungId = evtId, veranstaltungId = evtId,
onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) }, onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
onSave = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) }, onSaved = { _ -> onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
) )
} }
} }
@@ -65,14 +65,19 @@ object StoreV2 {
) )
) )
// Turniere für Neumarkt
TurnierStoreV2.add( TurnierStoreV2.add(
neumarktId, neumarktId,
TurnierV2(101, neumarktId, 26128, "CSN-C-NEU CSNP-C-NEU", "2026-04-10", "2026-04-12") TurnierV2(101, neumarktId, 26128, datumVon = "2026-04-10", datumBis = "2026-04-12", znsDataLoaded = true).apply {
kategorie.add("CSN-C-NEU")
kategorie.add("CSNP-C-NEU")
}
) )
TurnierStoreV2.add( TurnierStoreV2.add(
neumarktId, neumarktId,
TurnierV2(102, neumarktId, 26129, "CDN-C-NEU CDNP-C-NEU", "2026-04-10", "2026-04-12") TurnierV2(102, neumarktId, 26129, datumVon = "2026-04-10", datumBis = "2026-04-12", znsDataLoaded = true).apply {
kategorie.add("CDN-C-NEU")
kategorie.add("CDNP-C-NEU")
}
) )
// 2. Linz 2026 (ID 200) // 2. Linz 2026 (ID 200)
@@ -88,7 +93,11 @@ object StoreV2 {
beschreibung = "Großes Reit-Event am Ebelsberger Schlosspark." beschreibung = "Großes Reit-Event am Ebelsberger Schlosspark."
) )
) )
TurnierStoreV2.add(linzId, TurnierV2(201, linzId, 26500, "CSN-B*", "2026-05-20", "2026-05-24")) TurnierStoreV2.add(
linzId,
TurnierV2(201, linzId, 26500, datumVon = "2026-05-20", datumBis = "2026-05-24", znsDataLoaded = true).apply {
kategorie.add("CSN-B*")
})
// 3. Ein historisches Event (ID 300) // 3. Ein historisches Event (ID 300)
addEventFirst( addEventFirst(
@@ -4,17 +4,23 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
@@ -497,7 +503,10 @@ fun VeranstaltungKonfigV2(
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
val isDateRangeInvalid = dateVon != null && dateBis != null && dateBis.isBefore(dateVon) val today = LocalDate.now()
val isStartInPast = dateVon != null && dateVon.isBefore(today)
val isDateRangeInvalid =
(dateVon != null && dateBis != null && dateBis.isBefore(dateVon)) || isStartInPast
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField( OutlinedTextField(
@@ -506,10 +515,11 @@ fun VeranstaltungKonfigV2(
label = { Text("Datum von") }, label = { Text("Datum von") },
modifier = Modifier.weight(1f).clickable { showDatePickerVon = true }, modifier = Modifier.weight(1f).clickable { showDatePickerVon = true },
enabled = false, enabled = false,
isError = isStartInPast,
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface, disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledBorderColor = MaterialTheme.colorScheme.outline, disabledBorderColor = if (isStartInPast) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, disabledLabelColor = if (isStartInPast) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
), ),
@@ -517,6 +527,11 @@ fun VeranstaltungKonfigV2(
IconButton(onClick = { showDatePickerVon = true }) { IconButton(onClick = { showDatePickerVon = true }) {
Icon(Icons.Default.DateRange, contentDescription = "Datum wählen") Icon(Icons.Default.DateRange, contentDescription = "Datum wählen")
} }
},
supportingText = {
if (isStartInPast) {
Text("Startdatum darf nicht in der Vergangenheit liegen.")
}
} }
) )
OutlinedTextField( OutlinedTextField(
@@ -643,7 +658,9 @@ fun VeranstaltungKonfigV2(
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
val rangeInvalid = dVon != null && dBis != null && dBis.isBefore(dVon) val today2 = LocalDate.now()
val startInPast = dVon != null && dVon.isBefore(today2)
val rangeInvalid = (dVon != null && dBis != null && dBis.isBefore(dVon)) || startInPast
titel.isNotBlank() && von.isNotBlank() && !rangeInvalid titel.isNotBlank() && von.isNotBlank() && !rangeInvalid
} }
@@ -662,9 +679,16 @@ data class TurnierV2(
val id: Long, val id: Long,
val veranstaltungId: Long, val veranstaltungId: Long,
val turnierNr: Int, val turnierNr: Int,
var kategorie: String, var typ: String = "ÖTO (National)",
var znsDataLoaded: Boolean = false,
var sparten: SnapshotStateList<String> = mutableStateListOf(),
var klassen: SnapshotStateList<String> = mutableStateListOf(),
var kategorie: SnapshotStateList<String> = mutableStateListOf(),
var datumVon: String, var datumVon: String,
var datumBis: String?, var datumBis: String?,
var titel: String = "",
var subTitel: String = "",
var sponsoren: SnapshotStateList<String> = mutableStateListOf(),
) )
object TurnierStoreV2 { object TurnierStoreV2 {
@@ -684,48 +708,100 @@ fun VeranstaltungUebersichtV2(
) { ) {
DesktopThemeV2 { DesktopThemeV2 {
val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId } val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { val turniere = remember(veranstaltungId) { TurnierStoreV2.list(veranstaltungId) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon( Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
Icons.AutoMirrored.Filled.ArrowBack, // Header
contentDescription = "Zurück", Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
modifier = Modifier.clickable { onBack() }) IconButton(onClick = onBack) {
Text(veranstaltung?.titel ?: "Veranstaltung", style = MaterialTheme.typography.titleLarge) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Column {
Text(
text = veranstaltung?.titel ?: "Veranstaltung",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
if (veranstaltung != null) {
Text(
text = "${veranstaltung.ort} | ${veranstaltung.datumVon}${veranstaltung.datumBis?.let { " $it" } ?: ""}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Button(onClick = onTurnierNeu) { Text("+ Neues Turnier") } ElevatedButton(
onClick = onTurnierNeu,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Neues Turnier")
}
} }
if (veranstaltung != null) { // KPI Dashboard
Text("Zeitraum: ${veranstaltung.datumVon}${veranstaltung.datumBis?.let { " $it" } ?: ""}") Row(
Text("Status: ${veranstaltung.status}") modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
KpiCard(
title = "Turniere",
value = turniere.size.toString(),
icon = Icons.Default.Event,
modifier = Modifier.weight(1f)
)
KpiCard(
title = "Nennungen",
value = if (veranstaltungId == 100L) "248" else "0",
icon = Icons.Default.Description,
modifier = Modifier.weight(1f)
)
KpiCard(
title = "Reiter",
value = if (veranstaltungId == 100L) "112" else "0",
icon = Icons.Default.Person,
modifier = Modifier.weight(1f)
)
KpiCard(
title = "Pferde",
value = if (veranstaltungId == 100L) "145" else "0",
icon = Icons.Default.Pets,
modifier = Modifier.weight(1f)
)
} }
val list = remember(veranstaltungId) { TurnierStoreV2.list(veranstaltungId) } // Turnierliste
if (list.isEmpty()) Text("Noch keine Turniere angelegt.", color = Color(0xFF6B7280)) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Zugeordnete Turniere", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
if (turniere.isNotEmpty()) {
Badge(containerColor = MaterialTheme.colorScheme.secondaryContainer) {
Text(turniere.size.toString(), color = MaterialTheme.colorScheme.onSecondaryContainer)
}
}
}
LazyColumn(Modifier.fillMaxSize()) { if (turniere.isEmpty()) {
items(list) { t -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { Text(
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { "Noch keine Turniere angelegt.",
Column(Modifier.weight(1f)) { style = MaterialTheme.typography.bodyMedium,
Text("Turnier ${t.turnierNr}", fontWeight = FontWeight.SemiBold) color = MaterialTheme.colorScheme.onSurfaceVariant
Text(t.kategorie, color = Color(0xFF6B7280)) )
Text("${t.datumVon}${t.datumBis?.let { " $it" } ?: ""}", color = Color(0xFF6B7280)) }
} } else {
Button(onClick = { onTurnierOpen(t.id) }) { Text("Zum Turnier") } LazyColumn(
Spacer(Modifier.width(8.dp)) modifier = Modifier.fillMaxSize(),
var confirm by remember { mutableStateOf(false) } verticalArrangement = Arrangement.spacedBy(12.dp),
if (confirm) { contentPadding = PaddingValues(bottom = 24.dp)
AlertDialog( ) {
onDismissRequest = { confirm = false }, items(turniere) { t ->
confirmButton = { TextButton(onClick = { TurnierStoreV2.remove(veranstaltungId, t.id); confirm = false }) { Text("Löschen") } }, TurnierCard(
dismissButton = { TextButton(onClick = { confirm = false }) { Text("Abbrechen") } }, turnier = t,
title = { Text("Turnier löschen?") }, onOpen = { onTurnierOpen(t.id) },
text = { Text("Dieses Turnier wird aus der Veranstaltung entfernt (Prototyp).") } onDelete = { TurnierStoreV2.remove(veranstaltungId, t.id) }
) )
}
IconButton(onClick = { confirm = true }) { Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) }
}
} }
} }
} }
@@ -733,6 +809,109 @@ fun VeranstaltungUebersichtV2(
} }
} }
@Composable
private fun KpiCard(
title: String,
value: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
modifier: Modifier = Modifier,
) {
ElevatedCard(modifier = modifier) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = androidx.compose.foundation.shape.CircleShape,
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer)
}
}
Column {
Text(title, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
private fun TurnierCard(
turnier: TurnierV2,
onOpen: () -> Unit,
onDelete: () -> Unit,
) {
var showDeleteConfirm by remember { mutableStateOf(false) }
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
onClick = onOpen
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Turnier #${turnier.turnierNr}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
turnier.kategorie.forEach { kat ->
SuggestionChip(
onClick = {},
label = { Text(kat, fontSize = 11.sp) }
)
}
}
}
Spacer(Modifier.height(4.dp))
Text(
text = "${turnier.datumVon}${turnier.datumBis?.let { " $it" } ?: ""}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onOpen) {
Text("Öffnen")
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = MaterialTheme.colorScheme.error)
}
}
}
}
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
confirmButton = {
TextButton(
onClick = {
onDelete()
showDeleteConfirm = false
},
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Löschen") }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text("Abbrechen") }
},
title = { Text("Turnier löschen?") },
text = { Text("Möchten Sie das Turnier #${turnier.turnierNr} wirklich löschen?") }
)
}
}
@Composable @Composable
fun TurnierWizardV2( fun TurnierWizardV2(
veranstalterId: Long, veranstalterId: Long,
@@ -742,70 +921,561 @@ fun TurnierWizardV2(
) { ) {
DesktopThemeV2 { DesktopThemeV2 {
val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId } val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { var currentStep by remember { mutableStateOf(1) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { var showZnsDialog by remember { mutableStateOf(false) }
Icon(
Icons.AutoMirrored.Filled.ArrowBack, // State für alle Felder
contentDescription = "Zurück", var nr by remember { mutableStateOf("") }
modifier = Modifier.clickable { onBack() }) var nrConfirmed by remember { mutableStateOf(false) }
Text("Neues Turnier", style = MaterialTheme.typography.titleLarge) var znsDataLoaded by remember { mutableStateOf(false) }
var typ by remember { mutableStateOf("ÖTO (National)") }
val sparten = remember { mutableStateListOf<String>() }
val klassen = remember { mutableStateListOf<String>() }
val kat = remember { mutableStateListOf<String>() }
var von by remember { mutableStateOf(veranstaltung?.datumVon ?: "") }
var bis by remember { mutableStateOf(veranstaltung?.datumBis ?: "") }
var titel by remember { mutableStateOf("") }
var subTitel by remember { mutableStateOf("") }
val sponsoren = remember { mutableStateListOf<String>() }
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
// Header mit Breadcrumbs-Optik
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Text("Neues Turnier anlegen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f))
Text(
"Schritt $currentStep von 3",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
} }
var nr by remember { mutableStateOf("") } LinearProgressIndicator(
var locked by remember { mutableStateOf(false) } progress = { currentStep / 3f },
// Kategorie wird gemäß Neumarkt-Logik automatisch vorbelegt aus der Turnier-Nr. modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)),
var kat by remember { mutableStateOf("") } )
var von by remember { mutableStateOf(veranstaltung?.datumVon ?: "") }
var bis by remember { mutableStateOf(veranstaltung?.datumBis ?: "") }
OutlinedTextField(value = nr, onValueChange = { Box(Modifier.weight(1f).fillMaxWidth()) {
if (!locked) nr = it.filter { ch -> ch.isDigit() }.take(5) when (currentStep) {
}, label = { Text("TurnierNr. (5stellig)") }, enabled = !locked, modifier = Modifier.fillMaxWidth()) 1 -> Step1Basics(
nr, { nr = it },
nrConfirmed, { nrConfirmed = it },
typ, { typ = it },
znsDataLoaded, { znsDataLoaded = it }
)
val nrValid = nr.length == 5 2 -> Step2Sparten(
Button(onClick = { sparten, klassen, kat,
// Auto-Mapping gemäß vorhandener Neumarkt-Dokumentation von, { von = it }, bis, { bis = it },
kat = when (nr) { veranstaltung
"26128" -> "CSN-C-NEU CSNP-C-NEU" )
"26129" -> "CDN-C-NEU CDNP-C-NEU"
else -> "" 3 -> Step3Branding(titel, { titel = it }, subTitel, { subTitel = it }, sponsoren)
} }
locked = true }
}, enabled = nrValid && !locked) { Text("Nummer bestätigen & initialisieren") }
if (!nrValid) Text("Genau 5 Ziffern erforderlich", color = Color(0xFFB00020))
val freigeschaltet = locked // Footer Navigation
// Kategorie-Auswahl gemäß Vorlage: Dropdown mit sinnvollen Optionen und Auto-Vorbelegung Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
val kategorien = remember { listOf("CDN-C-NEU CDNP-C-NEU", "CSN-C-NEU CSNP-C-NEU") } OutlinedButton(
var katMenu by remember { mutableStateOf(false) } onClick = { if (currentStep > 1) currentStep-- else onBack() }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { ) {
Text("Kategorie:") Text(if (currentStep == 1) "Abbrechen" else "Zurück")
Box { }
OutlinedButton(onClick = { if (freigeschaltet) katMenu = true }, enabled = freigeschaltet) {
Text(if (kat.isBlank()) "Kategorie wählen" else kat) val canContinue = when (currentStep) {
1 -> nr.length == 5 && nrConfirmed && znsDataLoaded
2 -> {
val vVon = veranstaltung?.datumVon?.let { LocalDate.parse(it) }
val vBis = veranstaltung?.datumBis?.let { LocalDate.parse(it) }
val tVon = try {
LocalDate.parse(von)
} catch (e: Exception) {
null
}
val tBis = if (bis.isBlank()) tVon else try {
LocalDate.parse(bis)
} catch (e: Exception) {
null
}
val dateValid = if (vVon != null && tVon != null) {
val startOk = !tVon.isBefore(vVon)
val endOk = if (vBis != null && tBis != null) !tBis.isAfter(vBis) && !tBis.isBefore(tVon) else true
startOk && endOk
} else true
sparten.isNotEmpty() && klassen.isNotEmpty() && kat.isNotEmpty() && von.isNotBlank() && dateValid
} }
DropdownMenu(expanded = katMenu, onDismissRequest = { katMenu = false }) {
kategorien.forEach { k -> 3 -> true
DropdownMenuItem(onClick = { kat = k; katMenu = false }, text = { Text(k) }) else -> false
}
Button(
onClick = {
if (currentStep < 3) {
if (currentStep == 1) {
// Auto-Mapping bei Schritt-Wechsel
if (kat.isEmpty()) {
if (nr == "26128") {
if (!kat.contains("CSN-C-NEU")) kat.add("CSN-C-NEU")
if (!kat.contains("CSNP-C-NEU")) kat.add("CSNP-C-NEU")
}
if (nr == "26129") {
if (!kat.contains("CDN-C-NEU")) kat.add("CDN-C-NEU")
if (!kat.contains("CDNP-C-NEU")) kat.add("CDNP-C-NEU")
}
}
}
currentStep++
} else {
val id = System.currentTimeMillis()
val newTurnier = TurnierV2(
id = id,
veranstaltungId = veranstaltungId,
turnierNr = nr.toInt(),
typ = typ,
znsDataLoaded = znsDataLoaded,
datumVon = von,
datumBis = bis.ifBlank { null },
titel = titel,
subTitel = subTitel
)
newTurnier.sparten.addAll(sparten)
newTurnier.klassen.addAll(klassen)
newTurnier.kategorie.addAll(kat)
newTurnier.sponsoren.addAll(sponsoren)
TurnierStoreV2.add(veranstaltungId, newTurnier)
onSaved(id)
}
},
enabled = canContinue
) {
Text(if (currentStep == 3) "Turnier erstellen" else "Weiter")
}
}
}
}
}
@Composable
private fun Step1Basics(
nr: String, onNrChange: (String) -> Unit,
nrConfirmed: Boolean, onNrConfirmedChange: (Boolean) -> Unit,
typ: String, onTypChange: (String) -> Unit,
znsDataLoaded: Boolean, onZnsDataLoadedChange: (Boolean) -> Unit
) {
var showImportProgress by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Turnier-Konfiguration", style = MaterialTheme.typography.titleLarge)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = nr,
onValueChange = {
onNrChange(it.filter { ch -> ch.isDigit() }.take(5))
onNrConfirmedChange(false)
},
label = { Text("Turnier-Nr. (z.B. 26128)") },
modifier = Modifier.weight(1f),
enabled = !nrConfirmed,
supportingText = { Text("5-stellige Nummer vom OePS") }
)
if (!nrConfirmed) {
Button(
onClick = { onNrConfirmedChange(true) },
enabled = nr.length == 5
) {
Text("Bestätigen")
}
} else {
Icon(Icons.Default.CheckCircle, "Bestätigt", tint = Color(0xFF4CAF50), modifier = Modifier.size(32.dp))
TextButton(onClick = { onNrConfirmedChange(false) }) { Text("Ändern") }
}
}
Column {
Text("Typ:", style = MaterialTheme.typography.labelLarge)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
listOf("ÖTO (National)", "FEI (International)").forEach { option ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { onTypChange(option) }) {
RadioButton(selected = typ == option, onClick = { onTypChange(option) })
Text(option)
}
}
}
Text(
if (typ.startsWith("ÖTO")) "Nationales Regelwerk kommt zum Einsatz" else "Internationales FEI-Reglement aktiv",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column {
Text("ZNS-Daten:", style = MaterialTheme.typography.labelLarge)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = { showImportProgress = true },
enabled = nrConfirmed && !znsDataLoaded
) {
Icon(Icons.Default.CloudDownload, null)
Spacer(Modifier.width(8.dp))
Text("Import via Internet")
}
OutlinedButton(
onClick = { showImportProgress = true },
enabled = nrConfirmed && !znsDataLoaded
) {
Icon(Icons.Default.Usb, null)
Spacer(Modifier.width(8.dp))
Text("Import via USB")
}
}
Spacer(Modifier.height(8.dp))
Surface(
color = if (znsDataLoaded) Color(0xFFE8F5E9) else Color(0xFFFFEBEE),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Error,
null,
tint = if (znsDataLoaded) Color(0xFF2E7D32) else Color(0xFFC62828)
)
Spacer(Modifier.width(12.dp))
Text(
if (znsDataLoaded) "ZNS-Daten geladen" else "Keine ZNS-Daten (Import erforderlich)",
color = if (znsDataLoaded) Color(0xFF2E7D32) else Color(0xFFC62828),
fontWeight = FontWeight.Bold
)
}
}
}
if (showImportProgress) {
AlertDialog(
onDismissRequest = { },
confirmButton = { },
title = { Text("ZNS Import") },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Text("Daten werden verarbeitet...")
Spacer(Modifier.height(16.dp))
CircularProgressIndicator()
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(2000)
onZnsDataLoadedChange(true)
showImportProgress = false
}
}
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Step2Sparten(
sparten: SnapshotStateList<String>,
klassen: SnapshotStateList<String>,
kat: SnapshotStateList<String>,
von: String, onVonChange: (String) -> Unit,
bis: String, onBisChange: (String) -> Unit,
veranstaltung: VeranstaltungV2?
) {
var showDatePickerVon by remember { mutableStateOf(false) }
var showDatePickerBis by remember { mutableStateOf(false) }
val vVon = veranstaltung?.datumVon?.let { LocalDate.parse(it) }
val vBis = veranstaltung?.datumBis?.let { LocalDate.parse(it) }
val tVon = try {
LocalDate.parse(von)
} catch (e: Exception) {
null
}
val tBis = if (bis.isBlank()) tVon else try {
LocalDate.parse(bis)
} catch (e: Exception) {
null
}
val isDateValid = if (vVon != null && tVon != null) {
val startOk = !tVon.isBefore(vVon)
val endOk = if (vBis != null && tBis != null) !tBis.isAfter(vBis) && !tBis.isBefore(tVon) else true
startOk && endOk
} else true
Column(verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Sparten & Klassen", style = MaterialTheme.typography.titleLarge)
// Datumsauswahl
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Zeitraum des Turniers:",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = von,
onValueChange = {},
label = { Text("Datum von") },
modifier = Modifier.weight(1f),
readOnly = true,
trailingIcon = {
IconButton(onClick = { showDatePickerVon = true }) {
Icon(Icons.Default.DateRange, contentDescription = "Kalender")
}
},
isError = !isDateValid && tVon != null && vVon != null && tVon.isBefore(vVon),
supportingText = {
if (!isDateValid && tVon != null && vVon != null && tVon.isBefore(vVon)) {
Text("Muss innerhalb der Veranstaltung liegen (${veranstaltung?.datumVon})")
}
}
)
OutlinedTextField(
value = bis,
onValueChange = {},
label = { Text("Datum bis (optional)") },
modifier = Modifier.weight(1f),
readOnly = true,
trailingIcon = {
IconButton(onClick = { showDatePickerBis = true }) {
Icon(Icons.Default.DateRange, contentDescription = "Kalender")
}
},
isError = !isDateValid && tBis != null && ((vBis != null && tBis.isAfter(vBis)) || (tVon != null && tBis.isBefore(
tVon
))),
supportingText = {
if (!isDateValid && tBis != null) {
if (vBis != null && tBis.isAfter(vBis)) Text("Darf nicht nach der Veranstaltung enden (${veranstaltung?.datumBis})")
else if (tVon != null && tBis.isBefore(tVon)) Text("Darf nicht vor dem Startdatum liegen")
}
}
)
}
}
if (showDatePickerVon) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = tVon?.atStartOfDay(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: System.currentTimeMillis()
)
DatePickerDialog(
onDismissRequest = { showDatePickerVon = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let {
onVonChange(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()).toLocalDate().toString())
}
showDatePickerVon = false
}) { Text("OK") }
}
) { DatePicker(state = datePickerState) }
}
if (showDatePickerBis) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = tBis?.atStartOfDay(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: tVon?.atStartOfDay(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: System.currentTimeMillis()
)
DatePickerDialog(
onDismissRequest = { showDatePickerBis = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let {
onBisChange(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()).toLocalDate().toString())
}
showDatePickerBis = false
}) { Text("OK") }
}
) { DatePicker(state = datePickerState) }
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(32.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text("Sparten:", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
listOf("Dressur", "Springen").forEach { option ->
FilterChip(
selected = sparten.contains(option),
onClick = { if (sparten.contains(option)) sparten.remove(option) else sparten.add(option) },
label = { Text(option) },
leadingIcon = if (sparten.contains(option)) {
{
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
}
} else null
)
}
}
}
}
Column {
Text("Klassen:", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
val allKlassen = listOf("C-NEU", "C", "B", "A", "L", "LM", "M", "S")
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
allKlassen.chunked(4).forEach { rowKlassen ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
rowKlassen.forEach { option ->
FilterChip(
selected = klassen.contains(option),
onClick = { if (klassen.contains(option)) klassen.remove(option) else klassen.add(option) },
label = { Text(option, modifier = Modifier.padding(horizontal = 8.dp)) },
leadingIcon = if (klassen.contains(option)) {
{
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
}
} else null
)
} }
} }
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { }
OutlinedTextField(value = von, onValueChange = { von = it }, label = { Text("von (YYYY-MM-DD)") }, enabled = freigeschaltet, modifier = Modifier.weight(1f))
OutlinedTextField(value = bis, onValueChange = { bis = it }, label = { Text("bis (YYYY-MM-DD)") }, enabled = freigeschaltet, modifier = Modifier.weight(1f))
}
val parentVon = veranstaltung?.datumVon
val parentBis = veranstaltung?.datumBis
val dateOk = freigeschaltet && von.isNotBlank() && (bis.isBlank() || bis >= von) &&
(parentVon == null || (von >= parentVon && (parentBis == null || (bis.isBlank() || bis <= parentBis))))
if (freigeschaltet && !dateOk) Text("Turnier-Datum muss im Veranstaltungszeitraum liegen", color = Color(0xFFB00020))
Button(onClick = { Column {
val id = System.currentTimeMillis() Text(
TurnierStoreV2.add(veranstaltungId, TurnierV2(id, veranstaltungId, nr.toInt(), kat, von, bis.ifBlank { null })) "Kategorie (Vorschläge):",
onSaved(id) style = MaterialTheme.typography.labelLarge,
}, enabled = freigeschaltet && nrValid && kat.isNotBlank() && dateOk) { Text("Speichern") } color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(8.dp))
val suggestions = remember(sparten.toList(), klassen.toList()) {
val list = mutableListOf<String>()
sparten.forEach { s ->
val prefix = if (s == "Dressur") "CDN" else "CSN"
klassen.forEach { k ->
val suffix = if (k == "C-NEU") "-C-NEU" else "-$k"
list.add("$prefix$suffix")
list.add("${prefix}P$suffix")
}
}
list.distinct()
}
if (sparten.isEmpty() || klassen.isEmpty()) {
Surface(
modifier = Modifier.fillMaxWidth().height(60.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text("Bitte Sparte(n) und Klasse(n) auswählen", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
suggestions.chunked(4).forEach { chunk ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
chunk.forEach { suggestion ->
InputChip(
selected = kat.contains(suggestion),
onClick = { if (kat.contains(suggestion)) kat.remove(suggestion) else kat.add(suggestion) },
label = { Text(suggestion) },
trailingIcon = if (kat.contains(suggestion)) {
{ Icon(Icons.Default.Check, null, modifier = Modifier.size(18.dp)) }
} else null,
colors = InputChipDefaults.inputChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
}
}
}
}
}
}
}
@Composable
private fun Step3Branding(
titel: String, onTitelChange: (String) -> Unit,
subTitel: String, onSubTitelChange: (String) -> Unit,
sponsoren: SnapshotStateList<String>
) {
Column(verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Turnier-Beschreibung", style = MaterialTheme.typography.titleLarge)
OutlinedTextField(
value = titel,
onValueChange = onTitelChange,
label = { Text("Titel (z.B. Frühjahrs-Turnier 2026)") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = subTitel,
onValueChange = onSubTitelChange,
label = { Text("Sub-Titel (z.B. KIDS CUP • PONY EINSTEIGER CUP OÖ)") },
modifier = Modifier.fillMaxWidth()
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Sponsoren", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.weight(1f))
TextButton(onClick = { sponsoren.add("") }) {
Icon(Icons.Default.Add, null)
Text("Sponsor hinzufügen")
}
}
if (sponsoren.isEmpty()) {
Surface(
modifier = Modifier.fillMaxWidth().height(100.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text("Noch keine Sponsoren hinzugefügt", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} else {
sponsoren.forEachIndexed { index, sponsor ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = sponsor,
onValueChange = { sponsoren[index] = it },
label = { Text("Sponsor Name") },
modifier = Modifier.weight(1f)
)
IconButton(onClick = { sponsoren.removeAt(index) }) {
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = MaterialTheme.colorScheme.error)
}
}
}
} }
} }
} }