From f44b2c81269320c01855248e132b3b0033469eb4 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Tue, 31 Mar 2026 17:33:07 +0200 Subject: [PATCH] feat(event-feature): enhance Veranstaltungs- & Turnier-Workflow - 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 --- .../events/domain/model/Veranstaltung.kt | 3 + .../VeranstaltungRepositoryImpl.kt | 6 + .../persistence/VeranstaltungTable.kt | 3 + .../masterdata/domain/model/DomVerein.kt | 1 + .../persistence/ExposedVereinRepository.kt | 4 + .../persistence/VereinAnsprechpersonTable.kt | 22 + .../infrastructure/persistence/VereinTable.kt | 1 + ...-03-31_Session_Log_Event_First_Workflow.md | 105 +++ .../presentation/TurnierDetailScreen.kt | 9 +- .../presentation/TurnierStammdatenTab.kt | 415 +++++---- .../screens/layout/DesktopMainLayout.kt | 14 +- .../kotlin/at/mocode/desktop/v2/Stores.kt | 17 +- .../mocode/desktop/v2/VeranstaltungScreens.kt | 856 ++++++++++++++++-- 13 files changed, 1199 insertions(+), 257 deletions(-) create mode 100644 backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinAnsprechpersonTable.kt diff --git a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/Veranstaltung.kt b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/Veranstaltung.kt index b30aefcf..5eea0ecd 100644 --- a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/Veranstaltung.kt +++ b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/Veranstaltung.kt @@ -40,7 +40,10 @@ data class Veranstaltung( // Basic Information var name: String, + var untertitel: String? = null, var beschreibung: String? = null, + var logoUrl: String? = null, + var sponsoren: String? = null, // JSON string or comma-separated for now // Dates @Serializable(with = KotlinLocalDateSerializer::class) diff --git a/backend/services/events/events-infrastructure/src/main/kotlin/at/mocode/events/infrastructure/persistence/VeranstaltungRepositoryImpl.kt b/backend/services/events/events-infrastructure/src/main/kotlin/at/mocode/events/infrastructure/persistence/VeranstaltungRepositoryImpl.kt index 946a3d06..00ce4129 100644 --- a/backend/services/events/events-infrastructure/src/main/kotlin/at/mocode/events/infrastructure/persistence/VeranstaltungRepositoryImpl.kt +++ b/backend/services/events/events-infrastructure/src/main/kotlin/at/mocode/events/infrastructure/persistence/VeranstaltungRepositoryImpl.kt @@ -153,7 +153,10 @@ class VeranstaltungRepositoryImpl : VeranstaltungRepository { return Veranstaltung( veranstaltungId = row[VeranstaltungTable.id].value, name = row[VeranstaltungTable.name], + untertitel = row[VeranstaltungTable.untertitel], beschreibung = row[VeranstaltungTable.beschreibung], + logoUrl = row[VeranstaltungTable.logoUrl], + sponsoren = row[VeranstaltungTable.sponsoren], startDatum = row[VeranstaltungTable.startDatum], endDatum = row[VeranstaltungTable.endDatum], ort = row[VeranstaltungTable.ort], @@ -173,7 +176,10 @@ class VeranstaltungRepositoryImpl : VeranstaltungRepository { */ private fun veranstaltungToStatement(statement: UpdateBuilder<*>, veranstaltung: Veranstaltung) { statement[VeranstaltungTable.name] = veranstaltung.name + statement[VeranstaltungTable.untertitel] = veranstaltung.untertitel statement[VeranstaltungTable.beschreibung] = veranstaltung.beschreibung + statement[VeranstaltungTable.logoUrl] = veranstaltung.logoUrl + statement[VeranstaltungTable.sponsoren] = veranstaltung.sponsoren statement[VeranstaltungTable.startDatum] = veranstaltung.startDatum statement[VeranstaltungTable.endDatum] = veranstaltung.endDatum statement[VeranstaltungTable.ort] = veranstaltung.ort diff --git a/backend/services/events/events-infrastructure/src/main/kotlin/at/mocode/events/infrastructure/persistence/VeranstaltungTable.kt b/backend/services/events/events-infrastructure/src/main/kotlin/at/mocode/events/infrastructure/persistence/VeranstaltungTable.kt index 61f33cab..724a6821 100644 --- a/backend/services/events/events-infrastructure/src/main/kotlin/at/mocode/events/infrastructure/persistence/VeranstaltungTable.kt +++ b/backend/services/events/events-infrastructure/src/main/kotlin/at/mocode/events/infrastructure/persistence/VeranstaltungTable.kt @@ -16,7 +16,10 @@ object VeranstaltungTable : UUIDTable("veranstaltungen") { // Basic Information val name = varchar("name", 255) + val untertitel = varchar("untertitel", 255).nullable() val beschreibung = text("beschreibung").nullable() + val logoUrl = varchar("logo_url", 255).nullable() + val sponsoren = text("sponsoren").nullable() // JSON array of Sponsor data // Dates val startDatum = date("start_datum") diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomVerein.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomVerein.kt index ecefa688..3e84c95f 100644 --- a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomVerein.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomVerein.kt @@ -67,6 +67,7 @@ data class DomVerein( // Status & Verwaltung var istAktiv: Boolean = true, + var logoUrl: String? = null, var bemerkungen: String? = null, var datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS, diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedVereinRepository.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedVereinRepository.kt index e78ac74c..d8df5941 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedVereinRepository.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedVereinRepository.kt @@ -34,6 +34,7 @@ class ExposedVereinRepository : VereinRepository { oepsRegionNummer = row[VereinTable.oepsRegionNummer], istVeranstalter = row[VereinTable.istVeranstalter], istAktiv = row[VereinTable.istAktiv], + logoUrl = row[VereinTable.logoUrl], bemerkungen = row[VereinTable.bemerkungen], datenQuelle = DatenQuelleE.valueOf(row[VereinTable.datenQuelle]), createdAt = row[VereinTable.createdAt], @@ -106,6 +107,7 @@ class ExposedVereinRepository : VereinRepository { it[oepsRegionNummer] = verein.oepsRegionNummer it[istVeranstalter] = verein.istVeranstalter it[istAktiv] = verein.istAktiv + it[logoUrl] = verein.logoUrl it[bemerkungen] = verein.bemerkungen it[datenQuelle] = verein.datenQuelle.name it[updatedAt] = verein.updatedAt @@ -127,6 +129,7 @@ class ExposedVereinRepository : VereinRepository { it[oepsRegionNummer] = verein.oepsRegionNummer it[istVeranstalter] = verein.istVeranstalter it[istAktiv] = verein.istAktiv + it[logoUrl] = verein.logoUrl it[bemerkungen] = verein.bemerkungen it[datenQuelle] = verein.datenQuelle.name it[createdAt] = verein.createdAt @@ -169,6 +172,7 @@ class ExposedVereinRepository : VereinRepository { it[oepsRegionNummer] = toUpdate.oepsRegionNummer it[istVeranstalter] = toUpdate.istVeranstalter it[istAktiv] = toUpdate.istAktiv + it[logoUrl] = toUpdate.logoUrl it[bemerkungen] = toUpdate.bemerkungen it[datenQuelle] = toUpdate.datenQuelle.name it[updatedAt] = toUpdate.updatedAt diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinAnsprechpersonTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinAnsprechpersonTable.kt new file mode 100644 index 00000000..32197e3a --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinAnsprechpersonTable.kt @@ -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) +} diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinTable.kt index 4da8570e..50823b44 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinTable.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinTable.kt @@ -24,6 +24,7 @@ object VereinTable : Table("verein") { val oepsRegionNummer = varchar("oeps_region_nummer", 10).nullable() val istVeranstalter = bool("ist_veranstalter").default(false) val istAktiv = bool("ist_aktiv").default(true) + val logoUrl = varchar("logo_url", 255).nullable() val bemerkungen = text("bemerkungen").nullable() val datenQuelle = varchar("daten_quelle", 50) val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) diff --git a/docs/99_Journal/2026-03-31_Session_Log_Event_First_Workflow.md b/docs/99_Journal/2026-03-31_Session_Log_Event_First_Workflow.md index 8c265cae..5305dbcf 100644 --- a/docs/99_Journal/2026-03-31_Session_Log_Event_First_Workflow.md +++ b/docs/99_Journal/2026-03-31_Session_Log_Event_First_Workflow.md @@ -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 - **Fehlerbehebung Desktop-Shell Build:** diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt index 432a8807..3e40f90f 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt @@ -1,6 +1,8 @@ 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.runtime.* import androidx.compose.ui.Modifier @@ -34,6 +36,11 @@ fun TurnierDetailScreen( ) { 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( "STAMMDATEN", "ORGANISATION", diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt index 0ce40018..5d14ca32 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt @@ -1,11 +1,11 @@ package at.mocode.turnier.feature.presentation +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.Usb +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* 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.unit.dp import androidx.compose.ui.unit.sp +import java.time.LocalDate private val PrimaryBlue = Color(0xFF1E3A8A) private val AccentBlue = Color(0xFF3B82F6) @@ -26,214 +27,328 @@ private val AccentBlue = Color(0xFF3B82F6) * - Turnier-Beschreibung: Titel, Sub-Titel * - Sponsoren */ +@OptIn(ExperimentalMaterial3Api::class) @Composable 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 typOto by remember { mutableStateOf(true) } - var spracheDe by remember { mutableStateOf(true) } - var sparteDressur by remember { mutableStateOf(false) } - var sparteSpringen by remember { mutableStateOf(false) } - var klasseC by remember { mutableStateOf(false) } - var klasseB by remember { mutableStateOf(false) } - var klasseA by remember { mutableStateOf(false) } - var datumVon by remember { mutableStateOf("") } - var datumBis by remember { mutableStateOf("") } + var nrConfirmed by remember { mutableStateOf(false) } + var znsDataLoaded by remember { mutableStateOf(false) } + var typ by remember { mutableStateOf("ÖTO (National)") } + + val sparten = remember { mutableStateListOf() } + val klassen = remember { mutableStateListOf() } + val kat = remember { mutableStateListOf() } + + var von by remember { mutableStateOf("") } + var bis by remember { mutableStateOf("") } + var ort by remember { mutableStateOf("") } + var titel by remember { mutableStateOf("") } var subTitel by remember { mutableStateOf("") } + val sponsoren = remember { mutableStateListOf() } + + 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( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { - // ── Turnier-Konfiguration ──────────────────────────────────────────── - SectionCard(title = "Turnier-Konfiguration") { + // ── Turnier-Konfiguration (Schritt 1 Logik) ─────────────────────────── + SectionCard(title = "Turnier-Konfiguration & ZNS") { FormRow("Turnier-Nr.:") { - OutlinedTextField( - value = turnierNr, - onValueChange = { turnierNr = it }, - placeholder = { Text("z.B. 26128", fontSize = 13.sp) }, - modifier = Modifier.width(200.dp).height(48.dp), - singleLine = true, - ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = turnierNr, + onValueChange = { if (it.length <= 5 && it.all { c -> c.isDigit() }) turnierNr = it }, + placeholder = { Text("5-stellig", fontSize = 13.sp) }, + 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:") { Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = typOto, onClick = { typOto = true }) - Text("OTO (National)", fontSize = 13.sp) - RadioButton(selected = !typOto, onClick = { typOto = false }) - Text("FEI (International)", fontSize = 13.sp) + FilterChip( + selected = typ == "ÖTO (National)", + onClick = { typ = "ÖTO (National)" }, + 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( - onClick = {}, - colors = ButtonDefaults.buttonColors(containerColor = AccentBlue), + onClick = { showZnsDialog = true }, + colors = ButtonDefaults.buttonColors(containerColor = AccentBlue) ) { - Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text("Import via Internet", fontSize = 13.sp) + Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Import via Internet") } - OutlinedButton(onClick = {}) { - Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text("Import via USB", fontSize = 13.sp) + OutlinedButton(onClick = { showZnsDialog = true }) { + Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Import via USB") } } - Text( - "Reiter-, Pferde-, Funktionärs- und Vereinsdaten vom OEPS Backend", - fontSize = 11.sp, - color = Color(0xFF6B7280), - ) - } - FormRow("Sprache:") { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = spracheDe, onClick = { spracheDe = true }) - Text("Deutsch", fontSize = 13.sp) - RadioButton(selected = !spracheDe, onClick = { spracheDe = false }) - Text("English", fontSize = 13.sp) + + val znsStatusColor = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = znsStatusColor, + modifier = Modifier.size(16.dp) + ) + Text( + if (znsDataLoaded) "ZNS-Daten geladen" else "Keine ZNS-Daten geladen", + 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) { - Checkbox(checked = sparteDressur, onCheckedChange = { sparteDressur = it }) - Text("Dressur", fontSize = 13.sp) - Checkbox(checked = sparteSpringen, onCheckedChange = { sparteSpringen = it }) - Text("Springen", fontSize = 13.sp) + } + + // ── Sparten & Kategorien (Schritt 2 Logik) ─────────────────────────── + SectionCard(title = "Reglement & Sparten") { + FormRow("Sparte:") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + 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) { - Checkbox(checked = klasseC, onCheckedChange = { klasseC = it }) - Text("C", fontSize = 13.sp) - Checkbox(checked = klasseB, onCheckedChange = { klasseB = it }) - Text("B", fontSize = 13.sp) - Checkbox(checked = klasseA, onCheckedChange = { klasseA = it }) - Text("A", fontSize = 13.sp) - } - } - 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("Klasse:") { + val klassenListe = listOf("C-NEU", "C", "B", "A", "L", "LM", "M", "S") + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + klassenListe.forEach { k -> + FilterChip( + selected = klassen.contains(k), + onClick = { if (klassen.contains(k)) klassen.remove(k) else klassen.add(k) }, + label = { Text(k) } ) } } } - FormRow("Datum:") { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + + FormRow("Kategorien:") { + // Logik zur Generierung der Kategorien + val suggested = mutableListOf() + 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( - value = datumVon, - onValueChange = { datumVon = it }, - placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) }, - modifier = Modifier.width(160.dp).height(48.dp), - singleLine = true, + value = von, + onValueChange = {}, + label = { Text("Von") }, + modifier = Modifier.width(160.dp).clickable { showDatePickerVon = true }, + readOnly = true, + trailingIcon = { Icon(Icons.Default.DateRange, null) } ) - Text("bis", fontSize = 13.sp) + Text("bis") OutlinedTextField( - value = datumBis, - onValueChange = { datumBis = it }, - placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) }, - modifier = Modifier.width(160.dp).height(48.dp), - singleLine = true, + value = bis, + onValueChange = {}, + label = { Text("Bis") }, + modifier = Modifier.width(160.dp).clickable { showDatePickerBis = 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 ───────────────────────────────────────────── - SectionCard(title = "Turnier-Beschreibung") { + // ── Branding (Schritt 3 Logik) ─────────────────────────────────────── + SectionCard(title = "Turnier-Branding") { OutlinedTextField( value = titel, onValueChange = { titel = it }, - placeholder = { Text("z.B. Frühjahrs-Turnier 2026", fontSize = 13.sp) }, label = { Text("Titel") }, - modifier = Modifier.fillMaxWidth().height(56.dp), - singleLine = true, + modifier = Modifier.fillMaxWidth() ) OutlinedTextField( value = subTitel, onValueChange = { subTitel = it }, - placeholder = { Text("z.B. KIDS CUP • PONY EINSTEIGER CUP OÖ", fontSize = 13.sp) }, label = { Text("Sub-Titel") }, - modifier = Modifier.fillMaxWidth().height(56.dp), - singleLine = true, + modifier = Modifier.fillMaxWidth() ) - } - // ── Sponsoren ──────────────────────────────────────────────────────── - SectionCard( - title = "Sponsoren", - action = { - TextButton(onClick = {}) { - Text("+ Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp) - } - }, - ) { - Surface( - modifier = Modifier.fillMaxWidth().height(80.dp), - color = Color(0xFFF9FAFB), - 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) - } + FormRow("Sponsoren:") { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + sponsoren.forEach { s -> + InputChip( + selected = true, + onClick = { sponsoren.remove(s) }, + label = { Text(s) }, + trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(14.dp)) } + ) + } + TextButton(onClick = { sponsoren.add("Neuer Sponsor") }) { + Text("+ Hinzufügen") } } } } - // ── Aktions-Buttons ────────────────────────────────────────────────── - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton(onClick = {}) { Text("Zurücksetzen") } - Spacer(Modifier.width(8.dp)) + // ── Footer ────────────────────────────────────────────────────────── + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Button( - onClick = {}, + onClick = { /* Speichern */ }, + enabled = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank() && titel.isNotBlank(), 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 private fun SectionCard( title: String, - action: @Composable (() -> Unit)? = null, - content: @Composable ColumnScope.() -> Unit, + content: @Composable ColumnScope.() -> Unit ) { Card( modifier = Modifier.fillMaxWidth(), 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)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue) - action?.invoke() - } + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = PrimaryBlue, fontWeight = FontWeight.Bold) + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) content() } } @@ -241,20 +356,14 @@ private fun SectionCard( @Composable private fun FormRow(label: String, content: @Composable ColumnScope.() -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - ) { + Row(Modifier.fillMaxWidth()) { Text( - text = label, - fontSize = 13.sp, + label, modifier = Modifier.width(140.dp).padding(top = 12.dp), - color = Color(0xFF374151), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { content() } } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt index 482697cf..79761011 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt @@ -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.PingViewModel 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.FakeVeranstaltungStore import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen @@ -388,7 +386,10 @@ private fun DesktopContentArea( // Turnier-Screens is AppScreen.TurnierDetail -> { 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( message = "Veranstaltung (ID=$evtId) nicht gefunden.", onBack = { onNavigate(AppScreen.Veranstaltungen) } @@ -397,7 +398,7 @@ private fun DesktopContentArea( TurnierDetailScreen( veranstaltungId = evtId, 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) } ) } else { - TurnierWizardV2( + at.mocode.desktop.v2.TurnierWizardV2( + veranstalterId = parent.id, veranstaltungId = evtId, onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) }, - onSave = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) }, + onSaved = { _ -> onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) }, ) } } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt index fcbbe4d6..1a13b234 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt @@ -65,14 +65,19 @@ object StoreV2 { ) ) - // Turniere für Neumarkt TurnierStoreV2.add( 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( 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) @@ -88,7 +93,11 @@ object StoreV2 { 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) addEventFirst( diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt index 7c2090fc..de59e303 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt @@ -4,17 +4,23 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -497,7 +503,10 @@ fun VeranstaltungKonfigV2( } catch (e: Exception) { 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)) { OutlinedTextField( @@ -506,10 +515,11 @@ fun VeranstaltungKonfigV2( label = { Text("Datum von") }, modifier = Modifier.weight(1f).clickable { showDatePickerVon = true }, enabled = false, + isError = isStartInPast, colors = OutlinedTextFieldDefaults.colors( disabledTextColor = MaterialTheme.colorScheme.onSurface, - disabledBorderColor = MaterialTheme.colorScheme.outline, - disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledBorderColor = if (isStartInPast) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline, + disabledLabelColor = if (isStartInPast) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant ), @@ -517,6 +527,11 @@ fun VeranstaltungKonfigV2( IconButton(onClick = { showDatePickerVon = true }) { Icon(Icons.Default.DateRange, contentDescription = "Datum wählen") } + }, + supportingText = { + if (isStartInPast) { + Text("Startdatum darf nicht in der Vergangenheit liegen.") + } } ) OutlinedTextField( @@ -643,7 +658,9 @@ fun VeranstaltungKonfigV2( } catch (e: Exception) { 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 } @@ -662,9 +679,16 @@ data class TurnierV2( val id: Long, val veranstaltungId: Long, val turnierNr: Int, - var kategorie: String, + var typ: String = "ÖTO (National)", + var znsDataLoaded: Boolean = false, + var sparten: SnapshotStateList = mutableStateListOf(), + var klassen: SnapshotStateList = mutableStateListOf(), + var kategorie: SnapshotStateList = mutableStateListOf(), var datumVon: String, var datumBis: String?, + var titel: String = "", + var subTitel: String = "", + var sponsoren: SnapshotStateList = mutableStateListOf(), ) object TurnierStoreV2 { @@ -684,48 +708,100 @@ fun VeranstaltungUebersichtV2( ) { DesktopThemeV2 { val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId } - Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Zurück", - modifier = Modifier.clickable { onBack() }) - Text(veranstaltung?.titel ?: "Veranstaltung", style = MaterialTheme.typography.titleLarge) + val turniere = remember(veranstaltungId) { TurnierStoreV2.list(veranstaltungId) } + + Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) { + // Header + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + IconButton(onClick = onBack) { + 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)) - 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) { - Text("Zeitraum: ${veranstaltung.datumVon}${veranstaltung.datumBis?.let { " – $it" } ?: ""}") - Text("Status: ${veranstaltung.status}") + // KPI Dashboard + Row( + 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) } - if (list.isEmpty()) Text("Noch keine Turniere angelegt.", color = Color(0xFF6B7280)) + // Turnierliste + 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()) { - items(list) { t -> - Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { - Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text("Turnier ${t.turnierNr}", fontWeight = FontWeight.SemiBold) - Text(t.kategorie, color = Color(0xFF6B7280)) - Text("${t.datumVon}${t.datumBis?.let { " – $it" } ?: ""}", color = Color(0xFF6B7280)) - } - Button(onClick = { onTurnierOpen(t.id) }) { Text("Zum Turnier") } - Spacer(Modifier.width(8.dp)) - var confirm by remember { mutableStateOf(false) } - if (confirm) { - AlertDialog( - onDismissRequest = { confirm = false }, - confirmButton = { TextButton(onClick = { TurnierStoreV2.remove(veranstaltungId, t.id); confirm = false }) { Text("Löschen") } }, - dismissButton = { TextButton(onClick = { confirm = false }) { Text("Abbrechen") } }, - title = { Text("Turnier löschen?") }, - text = { Text("Dieses Turnier wird aus der Veranstaltung entfernt (Prototyp).") } - ) - } - IconButton(onClick = { confirm = true }) { Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) } - } + if (turniere.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "Noch keine Turniere angelegt.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(bottom = 24.dp) + ) { + items(turniere) { t -> + TurnierCard( + turnier = t, + onOpen = { onTurnierOpen(t.id) }, + onDelete = { TurnierStoreV2.remove(veranstaltungId, t.id) } + ) } } } @@ -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 fun TurnierWizardV2( veranstalterId: Long, @@ -742,70 +921,561 @@ fun TurnierWizardV2( ) { DesktopThemeV2 { val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId } - Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Zurück", - modifier = Modifier.clickable { onBack() }) - Text("Neues Turnier", style = MaterialTheme.typography.titleLarge) + var currentStep by remember { mutableStateOf(1) } + var showZnsDialog by remember { mutableStateOf(false) } + + // State für alle Felder + var nr by remember { mutableStateOf("") } + var nrConfirmed by remember { mutableStateOf(false) } + var znsDataLoaded by remember { mutableStateOf(false) } + var typ by remember { mutableStateOf("ÖTO (National)") } + + val sparten = remember { mutableStateListOf() } + val klassen = remember { mutableStateListOf() } + val kat = remember { mutableStateListOf() } + 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() } + + 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("") } - var locked by remember { mutableStateOf(false) } - // Kategorie wird gemäß Neumarkt-Logik automatisch vorbelegt aus der Turnier-Nr. - var kat by remember { mutableStateOf("") } - var von by remember { mutableStateOf(veranstaltung?.datumVon ?: "") } - var bis by remember { mutableStateOf(veranstaltung?.datumBis ?: "") } + LinearProgressIndicator( + progress = { currentStep / 3f }, + modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)), + ) - OutlinedTextField(value = nr, onValueChange = { - if (!locked) nr = it.filter { ch -> ch.isDigit() }.take(5) - }, label = { Text("Turnier‑Nr. (5‑stellig)") }, enabled = !locked, modifier = Modifier.fillMaxWidth()) + Box(Modifier.weight(1f).fillMaxWidth()) { + when (currentStep) { + 1 -> Step1Basics( + nr, { nr = it }, + nrConfirmed, { nrConfirmed = it }, + typ, { typ = it }, + znsDataLoaded, { znsDataLoaded = it } + ) - val nrValid = nr.length == 5 - Button(onClick = { - // Auto-Mapping gemäß vorhandener Neumarkt-Dokumentation - kat = when (nr) { - "26128" -> "CSN-C-NEU CSNP-C-NEU" - "26129" -> "CDN-C-NEU CDNP-C-NEU" - else -> "" + 2 -> Step2Sparten( + sparten, klassen, kat, + von, { von = it }, bis, { bis = it }, + veranstaltung + ) + + 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 - // Kategorie-Auswahl gemäß Vorlage: Dropdown mit sinnvollen Optionen und Auto-Vorbelegung - val kategorien = remember { listOf("CDN-C-NEU CDNP-C-NEU", "CSN-C-NEU CSNP-C-NEU") } - var katMenu by remember { mutableStateOf(false) } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Kategorie:") - Box { - OutlinedButton(onClick = { if (freigeschaltet) katMenu = true }, enabled = freigeschaltet) { - Text(if (kat.isBlank()) "Kategorie wählen" else kat) + // Footer Navigation + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedButton( + onClick = { if (currentStep > 1) currentStep-- else onBack() } + ) { + Text(if (currentStep == 1) "Abbrechen" else "Zurück") + } + + 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 -> - DropdownMenuItem(onClick = { kat = k; katMenu = false }, text = { Text(k) }) + + 3 -> true + 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, + klassen: SnapshotStateList, + kat: SnapshotStateList, + 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 = { - val id = System.currentTimeMillis() - TurnierStoreV2.add(veranstaltungId, TurnierV2(id, veranstaltungId, nr.toInt(), kat, von, bis.ifBlank { null })) - onSaved(id) - }, enabled = freigeschaltet && nrValid && kat.isNotBlank() && dateOk) { Text("Speichern") } + Column { + Text( + "Kategorie (Vorschläge):", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.height(8.dp)) + + val suggestions = remember(sparten.toList(), klassen.toList()) { + val list = mutableListOf() + 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 +) { + 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) + } + } + } } } }