diff --git a/docs/99_Journal/2026-04-17_Cleanup-Veranstaltung-Screens-V2-Removal.md b/docs/99_Journal/2026-04-17_Cleanup-Veranstaltung-Screens-V2-Removal.md new file mode 100644 index 00000000..0fbd1d02 --- /dev/null +++ b/docs/99_Journal/2026-04-17_Cleanup-Veranstaltung-Screens-V2-Removal.md @@ -0,0 +1,49 @@ +# Session Journal: 2026-04-17 - Aufräumarbeiten & Konsolidierung + +## 🎯 Ziele der Session + +1. **V2-Cleanup:** Entfernung aller `V2`-Suffixe aus dem Codebase (Modelle, Stores, Wizards), um eine konsolidierte " + Source of Truth" zu schaffen. +2. **Refactoring:** Zerlegung der massiven `VeranstaltungKonfig`-Komponente in wartbare Teil-Module. +3. **Duplikat-Entfernung:** Zentralisierung von UI-Logik (DatePicker, Validierung) zur Reduzierung von Code-Duplikaten. + +## 🛠️ Durchgeführte Änderungen + +### 🧹 1. Konsolidierung der Benamung (V2-Entfernung) + +* **Änderungen:** + * `VeranstaltungKonfigV2` -> `VeranstaltungKonfig` + * `VeranstaltungV2` -> `Veranstaltung` + * `TurnierV2` -> `Turnier` + * `StoreV2` -> `Store` + * `TurnierStoreV2` -> `TurnierStore` + * `TurnierWizardV2` -> `TurnierWizard` +* **Grund:** Umsetzung der Vereinbarung, nur noch eine "echte" Version zu pflegen und Altlasten aus Migrationsphasen zu + entfernen. Alle Referenzen im gesamten Projekt (`DesktopMainLayout.kt`, `ManagementScreens.kt`, `main.kt`) wurden + erfolgreich aktualisiert. + +### 🏗️ 2. Refactoring `VeranstaltungScreens.kt` + +* **Extraktion:** Die Wizard-Schritte wurden in eigenständige Composable-Funktionen ausgelagert: + * `Step1Veranstalter`: Auswahl aus ZNS/Lokal-Bestand. + * `Step2Basisdaten`: Titel, Zeitraum, Ort, Disziplinen. + * `Step3Details`: Logo, Sponsoren, Bewerbs-Management. +* **Zentralisierung:** + * Neue Komponente `AppDatePickerDialog` zur Vermeidung von dreifach redundantem Dialog-Code. + * Konsolidierte Validierungslogik für den Veranstaltungszeitraum. + +### 🏷️ 3. Fehlerbehebung & Qualitätssicherung + +* **Syntax-Fix:** Korrektur von Klammerfehlern, die während des Refactorings in der großen `VeranstaltungScreens.kt` + entstanden sind. +* **Linting:** Erfolgreiche Validierung der Dateien `VeranstaltungScreens.kt`, `Stores.kt` und `DesktopMainLayout.kt`. + +## ✅ Ergebnis & Status + +* Der Code ist nun wesentlich modularer und besser lesbar. +* Die Benamung ist konsistent ohne verwirrende Versions-Suffixe. +* Redundante Logik-Blöcke (besonders beim Datum-Handling) wurden eliminiert. + +--- +**🏗️ [Lead Architect]** & **🧹 [Curator]** +Datum: 17. April 2026 | Status: Abgeschlossen diff --git a/docs/99_Journal/2026-04-17_Desktop-Wizard-OETO-ZNS-Update.md b/docs/99_Journal/2026-04-17_Desktop-Wizard-OETO-ZNS-Update.md index 4f32fc79..ae8a7af0 100644 --- a/docs/99_Journal/2026-04-17_Desktop-Wizard-OETO-ZNS-Update.md +++ b/docs/99_Journal/2026-04-17_Desktop-Wizard-OETO-ZNS-Update.md @@ -50,11 +50,22 @@ * Anzeige der letzten Sync-Version (z.B. `ZNS: V12` oder `ZNS: Kein Sync`). * Farbliche Kennzeichnung (Grün/Gelb/Rot) je nach Synchronisationsstand. +### 🏗️ 5. Fachlich: Disziplinen & Bewerbe (Schritt 2 & 3) + +* **Datei:** `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt` +* **Änderung:** + * **Schritt 2:** Felder für PLZ und Disziplin-Auswahl (Springen, Dressur, etc.) hinzugefügt. + * **Schritt 3:** Komplettes Bewerbs-Management implementiert. User können Prüfungsnummern, Klassen und Bezeichnungen + erfassen. + * **Validierung:** Der Wizard lässt sich erst finalisieren, wenn mindestens ein Bewerb angelegt wurde. +* **Grund:** Vorbereitung der Datenstruktur für den OEPS-Export und Verbesserung der fachlichen Abdeckung im Wizard. + ## ✅ Ergebnis & Status * Das Consul-Dashboard sollte nun einen stabilen "Grün"-Status für den `masterdata-service` anzeigen. * Der Desktop-Wizard leitet den User fachlich korrekt durch die Turnier-Anlage. * Der User hat jederzeit volle Transparenz über den Stand seiner lokalen ZNS-Daten. +* Die Erfassung von Bewerben legt den Grundstein für die spätere Nennungs- und Ergebnisverwaltung. --- **🏗️ [Lead Architect]** & **🧹 [Curator]** diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt index 21095f2b..ca578ebc 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt @@ -12,13 +12,13 @@ import at.mocode.frontend.core.localdb.localDbModule import at.mocode.frontend.core.network.networkModule import at.mocode.frontend.core.sync.di.syncModule import at.mocode.frontend.features.billing.di.billingModule -import at.mocode.frontend.features.profile.di.profileModule -import at.mocode.frontend.features.verein.di.vereinFeatureModule import at.mocode.frontend.features.nennung.di.nennungFeatureModule import at.mocode.frontend.features.pferde.di.pferdeModule +import at.mocode.frontend.features.profile.di.profileModule import at.mocode.frontend.features.reiter.di.reiterModule -import at.mocode.turnier.feature.di.turnierFeatureModule +import at.mocode.frontend.features.verein.di.vereinFeatureModule import at.mocode.ping.feature.di.pingFeatureModule +import at.mocode.turnier.feature.di.turnierFeatureModule import at.mocode.zns.feature.di.znsImportModule import kotlinx.coroutines.runBlocking import org.koin.core.context.GlobalContext @@ -48,7 +48,7 @@ fun main() = application { } println("[DesktopApp] KOIN initialisiert") // Testdaten für Prototyp laden - at.mocode.desktop.v2.StoreV2.seed() + at.mocode.desktop.v2.Store.seed() } catch (e: Exception) { println("[DesktopApp] Koin-Warnung: ${e.message}") } 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 21a77751..d9bc8b2d 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 @@ -660,7 +660,7 @@ private fun DesktopContentArea( is AppScreen.VeranstaltungKonfig -> { val vId = currentScreen.veranstalterId // Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard - at.mocode.desktop.v2.VeranstaltungKonfigV2( + at.mocode.desktop.v2.VeranstaltungKonfig( veranstalterId = vId, onBack = onBack, onSaved = { evtId, finalVId -> onNavigate(AppScreen.VeranstaltungProfil(finalVId, evtId)) }, @@ -671,12 +671,12 @@ private fun DesktopContentArea( is AppScreen.VeranstaltungProfil -> { val vId = currentScreen.veranstalterId val evtId = currentScreen.veranstaltungId - if (at.mocode.desktop.v2.StoreV2.vereine.none { it.id == vId }) { + if (at.mocode.desktop.v2.Store.vereine.none { it.id == vId }) { InvalidContextNotice( message = "Veranstalter (ID=$vId) nicht gefunden.", onBack = onBack ) - } else if (at.mocode.desktop.v2.StoreV2.eventsFor(vId).none { it.id == evtId }) { + } else if (at.mocode.desktop.v2.Store.eventsFor(vId).none { it.id == evtId }) { InvalidContextNotice( message = "Veranstaltung (ID=$evtId) gehört nicht zu Veranstalter #$vId.", onBack = onBack @@ -687,17 +687,17 @@ private fun DesktopContentArea( veranstaltungId = evtId, onBack = onBack, onTurnierNeu = { - val veranstaltung = at.mocode.desktop.v2.StoreV2.eventsFor(vId).firstOrNull { it.id == evtId } - val list = at.mocode.desktop.v2.TurnierStoreV2.list(evtId) + val veranstaltung = at.mocode.desktop.v2.Store.eventsFor(vId).firstOrNull { it.id == evtId } + val list = at.mocode.desktop.v2.TurnierStore.list(evtId) val newId = (list.maxOfOrNull { it.id } ?: 0L) + 1L - val draft = at.mocode.desktop.v2.TurnierV2( + val draft = at.mocode.desktop.v2.Turnier( id = newId, veranstaltungId = evtId, turnierNr = 0, datumVon = veranstaltung?.datumVon ?: "", datumBis = veranstaltung?.datumBis, ) - at.mocode.desktop.v2.TurnierStoreV2.add(evtId, draft) + at.mocode.desktop.v2.TurnierStore.add(evtId, draft) onNavigate(AppScreen.TurnierDetail(evtId, newId)) }, onTurnierOpen = { tId -> onNavigate(AppScreen.TurnierDetail(evtId, tId)) }, @@ -711,21 +711,21 @@ private fun DesktopContentArea( veranstaltungId = currentScreen.id, onBack = onBack, onTurnierNeu = { - val v = at.mocode.desktop.v2.StoreV2.vereine.firstOrNull { vv -> - at.mocode.desktop.v2.StoreV2.eventsFor(vv.id).any { it.id == currentScreen.id } + val v = at.mocode.desktop.v2.Store.vereine.firstOrNull { vv -> + at.mocode.desktop.v2.Store.eventsFor(vv.id).any { it.id == currentScreen.id } } val veranstaltung = - v?.let { at.mocode.desktop.v2.StoreV2.eventsFor(it.id).firstOrNull { e -> e.id == currentScreen.id } } - val list = at.mocode.desktop.v2.TurnierStoreV2.list(currentScreen.id) + v?.let { at.mocode.desktop.v2.Store.eventsFor(it.id).firstOrNull { e -> e.id == currentScreen.id } } + val list = at.mocode.desktop.v2.TurnierStore.list(currentScreen.id) val newId = (list.maxOfOrNull { it.id } ?: 0L) + 1L - val draft = at.mocode.desktop.v2.TurnierV2( + val draft = at.mocode.desktop.v2.Turnier( id = newId, veranstaltungId = currentScreen.id, turnierNr = 0, datumVon = veranstaltung?.datumVon ?: "", datumBis = veranstaltung?.datumBis, ) - at.mocode.desktop.v2.TurnierStoreV2.add(currentScreen.id, draft) + at.mocode.desktop.v2.TurnierStore.add(currentScreen.id, draft) onNavigate(AppScreen.TurnierDetail(currentScreen.id, newId)) }, onTurnierOeffnen = { tid -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tid)) }, @@ -739,8 +739,8 @@ private fun DesktopContentArea( // Turnier-Screens is AppScreen.TurnierDetail -> { val evtId = currentScreen.veranstaltungId - val parent = at.mocode.desktop.v2.StoreV2.vereine.firstOrNull { v -> - at.mocode.desktop.v2.StoreV2.eventsFor(v.id).any { it.id == evtId } + val parent = at.mocode.desktop.v2.Store.vereine.firstOrNull { v -> + at.mocode.desktop.v2.Store.eventsFor(v.id).any { it.id == evtId } } if (parent == null) { InvalidContextNotice( @@ -748,7 +748,7 @@ private fun DesktopContentArea( onBack = onBack ) } else { - val veranstaltung = at.mocode.desktop.v2.StoreV2.eventsFor(parent.id).firstOrNull { it.id == evtId } + val veranstaltung = at.mocode.desktop.v2.Store.eventsFor(parent.id).firstOrNull { it.id == evtId } val blCode = parent.oepsNummer.split("-").getOrNull(1) ?: "" val bundesland = mapOepsToBundesland(blCode) TurnierDetailScreen( @@ -769,8 +769,8 @@ private fun DesktopContentArea( is AppScreen.TurnierNeu -> { val evtId = currentScreen.veranstaltungId // V2: Wir erlauben Turnier-Nr nur, wenn die Veranstaltung im V2-Store existiert - val parent = at.mocode.desktop.v2.StoreV2.vereine.firstOrNull { v -> - at.mocode.desktop.v2.StoreV2.eventsFor(v.id).any { it.id == evtId } + val parent = at.mocode.desktop.v2.Store.vereine.firstOrNull { v -> + at.mocode.desktop.v2.Store.eventsFor(v.id).any { it.id == evtId } } if (parent == null) { InvalidContextNotice( @@ -778,7 +778,7 @@ private fun DesktopContentArea( onBack = onBack ) } else { - at.mocode.desktop.v2.TurnierWizardV2( + at.mocode.desktop.v2.TurnierWizard( veranstalterId = parent.id, veranstaltungId = evtId, onBack = onBack, diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/ManagementScreens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/ManagementScreens.kt index ec892e3a..d4f72d23 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/ManagementScreens.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/ManagementScreens.kt @@ -6,9 +6,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.* -import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -140,7 +142,7 @@ data class TableColumn( @Composable fun PferdeVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) { - val pferde = StoreV2.pferde + val pferde = Store.pferde var filter by remember { mutableStateOf("") } val filteredItems = if (filter.isEmpty()) pferde else pferde.filter { it.name.contains(filter, ignoreCase = true) || it.feiId?.contains(filter, ignoreCase = true) == true @@ -162,14 +164,14 @@ fun PferdeVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) { onBack = onBack, onNew = { /* CRUD Logik */ }, onEdit = { onEdit(it.id) }, - onDelete = { StoreV2.pferde.remove(it) }, + onDelete = { Store.pferde.remove(it) }, onSearch = { filter = it } ) } @Composable fun ReiterVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) { - val reiter = StoreV2.reiter + val reiter = Store.reiter var filter by remember { mutableStateOf("") } val filteredItems = if (filter.isEmpty()) reiter else reiter.filter { it.vorname.contains(filter, ignoreCase = true) || it.nachname.contains( @@ -192,14 +194,14 @@ fun ReiterVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) { onBack = onBack, onNew = { }, onEdit = { onEdit(it.id) }, - onDelete = { StoreV2.reiter.remove(it) }, + onDelete = { Store.reiter.remove(it) }, onSearch = { filter = it } ) } @Composable fun VereinVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) { - val vereine = StoreV2.vereine + val vereine = Store.vereine var filter by remember { mutableStateOf("") } val filteredItems = if (filter.isEmpty()) vereine else vereine.filter { it.name.contains(filter, ignoreCase = true) || it.oepsNummer.contains(filter, ignoreCase = true) @@ -218,14 +220,14 @@ fun VereinVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) { onBack = onBack, onNew = { }, onEdit = { onEdit(it.id) }, - onDelete = { StoreV2.vereine.remove(it) }, + onDelete = { Store.vereine.remove(it) }, onSearch = { filter = it } ) } @Composable fun FunktionaerVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) { - val funktionaere = StoreV2.funktionaere + val funktionaere = Store.funktionaere var filter by remember { mutableStateOf("") } val filteredItems = if (filter.isEmpty()) funktionaere else funktionaere.filter { it.vorname.contains(filter, ignoreCase = true) || it.nachname.contains(filter, ignoreCase = true) @@ -244,7 +246,7 @@ fun FunktionaerVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) { onBack = onBack, onNew = { }, onEdit = { onEdit(it.id) }, - onDelete = { StoreV2.funktionaere.remove(it) }, + onDelete = { Store.funktionaere.remove(it) }, onSearch = { filter = it } ) } @@ -253,7 +255,7 @@ fun FunktionaerVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) { fun VeranstalterVerwaltungScreen(onBack: () -> Unit, onNew: () -> Unit, onEdit: (Long) -> Unit) { // Veranstalter sind in unserem System eigentlich Vereine, die Veranstaltungen ausrichten // Wir nutzen hier die 'vereine' Liste aus dem Store. - val vereine = StoreV2.vereine + val vereine = Store.vereine var filter by remember { mutableStateOf("") } val filteredItems = if (filter.isEmpty()) vereine else vereine.filter { it.name.contains(filter, ignoreCase = true) || it.oepsNummer.contains(filter, ignoreCase = true) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt index e061caf0..e24b4cfb 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt @@ -431,7 +431,7 @@ fun OnboardingScreenPreview() { @Composable fun PferdProfilV2(id: Long, onBack: () -> Unit) { DesktopThemeV2 { - val pferd = remember(id) { StoreV2.pferde.firstOrNull { it.id == id } } + val pferd = remember(id) { Store.pferde.firstOrNull { it.id == id } } if (pferd == null) { Text("Pferd nicht gefunden"); return@DesktopThemeV2 } @@ -506,7 +506,7 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) { @Composable fun ReiterProfilV2(id: Long, onBack: () -> Unit) { DesktopThemeV2 { - val r = remember(id) { StoreV2.reiter.firstOrNull { it.id == id } } + val r = remember(id) { Store.reiter.firstOrNull { it.id == id } } if (r == null) { Text("Reiter nicht gefunden"); return@DesktopThemeV2 } @@ -590,7 +590,7 @@ fun ReiterProfilV2(id: Long, onBack: () -> Unit) { @Composable fun VereinProfilV2(id: Long, onBack: () -> Unit) { DesktopThemeV2 { - val v = remember(id) { StoreV2.vereine.firstOrNull { it.id == id } } + val v = remember(id) { Store.vereine.firstOrNull { it.id == id } } if (v == null) { Text("Verein nicht gefunden"); return@DesktopThemeV2 } @@ -681,7 +681,7 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) { @Composable fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) { DesktopThemeV2 { - val f = remember(id) { StoreV2.funktionaere.firstOrNull { it.id == id } } + val f = remember(id) { Store.funktionaere.firstOrNull { it.id == id } } if (f == null) { Text("Funktionär nicht gefunden"); return@DesktopThemeV2 } @@ -782,7 +782,7 @@ fun VeranstalterAuswahlV2( var selectedId by remember { mutableStateOf(null) } LazyColumn(Modifier.fillMaxSize()) { - items(StoreV2.vereine) { v -> + items(Store.vereine) { v -> val sel = selectedId == v.id Card( modifier = Modifier @@ -822,14 +822,14 @@ fun VeranstalterDetailV2( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) - val verein = StoreV2.vereine.firstOrNull { it.id == veranstalterId } + val verein = Store.vereine.firstOrNull { it.id == veranstalterId } Text(verein?.name ?: "Veranstalter", style = MaterialTheme.typography.titleLarge) Spacer(Modifier.weight(1f)) Button(onClick = onNeuVeranstaltung) { Text("+ Neue Veranstaltung") } } // Veranstalter Vorschau-Karte mit Bearbeiten-Dialog - val verein = remember(veranstalterId) { StoreV2.vereine.firstOrNull { it.id == veranstalterId } } + val verein = remember(veranstalterId) { Store.vereine.firstOrNull { it.id == veranstalterId } } if (verein != null) { var editOpen by remember { mutableStateOf(false) } Card(Modifier.fillMaxWidth()) { @@ -953,7 +953,7 @@ fun VeranstalterDetailV2( } } - val events = StoreV2.eventsFor(veranstalterId) + val events = Store.eventsFor(veranstalterId) // Filter-/Suchmaske var search by remember { mutableStateOf("") } OutlinedTextField( @@ -992,7 +992,7 @@ fun VeranstalterDetailV2( onDismissRequest = { confirm = false }, confirmButton = { TextButton(onClick = { - StoreV2.removeEvent(veranstalterId, evt.id) + Store.removeEvent(veranstalterId, evt.id) confirm = false }) { Text("Löschen") } }, 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 24d8e8a5..336b0dfe 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 @@ -71,7 +71,7 @@ data class Funktionaer( var istAktiv: Boolean = true, ) -data class VeranstaltungV2( +data class Veranstaltung( val id: Long, var veranstalterId: Long, var titel: String, @@ -85,7 +85,7 @@ data class VeranstaltungV2( var sponsoren: SnapshotStateList = mutableStateListOf(), ) -object StoreV2 { +object Store { val pferde: SnapshotStateList = mutableStateListOf( Pferd( id = 1, @@ -268,7 +268,7 @@ object StoreV2 { return id } - private val veranstaltungen: MutableMap> = mutableMapOf() + private val veranstaltungen: MutableMap> = mutableMapOf() fun seed() { // Falls bereits Daten da sind (außer den statischen Vereinen), nichts tun @@ -277,7 +277,7 @@ object StoreV2 { // 1. Neumarkt April 2026 (ID 100) val neumarktId = 100L addEventFirst( - 1, VeranstaltungV2( + 1, Veranstaltung( id = neumarktId, veranstalterId = 1, titel = "CSN-B* Neumarkt am Wallersee", @@ -289,17 +289,17 @@ object StoreV2 { ) ) - TurnierStoreV2.add( + TurnierStore.add( neumarktId, - TurnierV2(101, neumarktId, 26128, datumVon = "2026-04-24", datumBis = "2026-04-26", znsDataLoaded = true).apply { + Turnier(101, neumarktId, 26128, datumVon = "2026-04-24", datumBis = "2026-04-26", znsDataLoaded = true).apply { titel = "Springturnier Neumarkt" kategorie.add("CSN-B*") kategorie.add("CSNP-B") } ) - TurnierStoreV2.add( + TurnierStore.add( neumarktId, - TurnierV2(102, neumarktId, 26129, datumVon = "2026-04-24", datumBis = "2026-04-26", znsDataLoaded = true).apply { + Turnier(102, neumarktId, 26129, datumVon = "2026-04-24", datumBis = "2026-04-26", znsDataLoaded = true).apply { titel = "Dressurturnier Neumarkt" kategorie.add("CDN-B") kategorie.add("CDNP-B") @@ -309,7 +309,7 @@ object StoreV2 { // 2. Linz 2026 (ID 200) val linzId = 200L addEventFirst( - 2, VeranstaltungV2( + 2, Veranstaltung( id = linzId, veranstalterId = 2, titel = "Linzer Pferdefestival", @@ -319,15 +319,15 @@ object StoreV2 { beschreibung = "Große Reitsport-Veranstaltung am Ebelsberger Schlosspark." ) ) - TurnierStoreV2.add( + TurnierStore.add( linzId, - TurnierV2(201, linzId, 26500, datumVon = "2026-05-20", datumBis = "2026-05-24", znsDataLoaded = true).apply { + Turnier(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( - 1, VeranstaltungV2( + 1, Veranstaltung( id = 300L, veranstalterId = 1, titel = "Herbst-Turnier 2025", @@ -338,10 +338,10 @@ object StoreV2 { ) } - fun eventsFor(vereinId: Long): SnapshotStateList = + fun eventsFor(vereinId: Long): SnapshotStateList = veranstaltungen.getOrPut(vereinId) { mutableStateListOf() } - fun addEventFirst(vereinId: Long, v: VeranstaltungV2) { + fun addEventFirst(vereinId: Long, v: Veranstaltung) { eventsFor(vereinId).add(0, v) } @@ -351,5 +351,5 @@ object StoreV2 { if (idx >= 0) list.removeAt(idx) } - fun allEvents(): List = veranstaltungen.values.flatten() + fun allEvents(): List = veranstaltungen.values.flatten() } 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 b5608e14..e4278bf0 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 @@ -44,8 +44,8 @@ fun VeranstaltungVerwaltung( onNavigateToZnsImport: () -> Unit ) { DesktopThemeV2 { - val allVeranstaltungen = remember { StoreV2.allEvents() } - val vereine = StoreV2.vereine + val allVeranstaltungen = remember { Store.allEvents() } + val vereine = Store.vereine var searchQuery by remember { mutableStateOf("") } var selectedStatus by remember { mutableStateOf(null) } @@ -223,7 +223,7 @@ fun VeranstalterAnlegenWizard( val results = remember(searchQuery) { if (searchQuery.length < 2) emptyList() - else StoreV2.oepsStammdaten.filter { + else Store.oepsStammdaten.filter { it.name.contains(searchQuery, ignoreCase = true) || (it.ort?.contains(searchQuery, ignoreCase = true) ?: false) || it.oepsNummer.contains(searchQuery, ignoreCase = true) @@ -295,7 +295,7 @@ fun VeranstalterAnlegenWizard( TextButton(onClick = { step = 1 }) { Text("Zurück zur Suche") } Button( onClick = { - val newId = StoreV2.addVerein(name, oeps, ort) + val newId = Store.addVerein(name, oeps, ort) onVereinCreated(newId) }, enabled = name.isNotBlank() && ort.isNotBlank() @@ -309,110 +309,550 @@ fun VeranstalterAnlegenWizard( } } +class BewerbData( + val nummer: String, + val abteilung: String?, + val klasse: String, + val disziplin: String, + val bezeichnung: String +) + +fun Long?.toLocalDate(): LocalDate? { + if (this == null) return null + return Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()).toLocalDate() +} + @OptIn(ExperimentalMaterial3Api::class) @Composable -fun VeranstaltungKonfigV2( +fun AppDatePickerDialog( + onDismiss: () -> Unit, + onDateSelected: (LocalDate) -> Unit, +) { + val datePickerState = rememberDatePickerState() + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis.toLocalDate()?.let { + onDateSelected(it) + } + onDismiss() + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + ) { + DatePicker(state = datePickerState) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Step1Veranstalter( + znsState: at.mocode.frontend.core.domain.zns.ZnsImportState, + znsImporter: ZnsImportProvider, + selectedVereinId: Long, + onVereinSelected: (Long) -> Unit, + onVeranstalterCreated: (Long) -> Unit, +) { + var showVereinNeu by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + "Daten-Akquise & Veranstalter", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + ZnsImportWizardSection( + state = znsState, + onFileSelect = { path -> znsImporter.onFileSelected(path) }, + onStartImport = { znsImporter.startImport(mode = "LIGHT") }, + onReset = { znsImporter.reset() } + ) + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp) + ) { + Button( + onClick = { + znsImporter.syncFromCloud { remoteList -> + remoteList.forEach { remote -> + Store.vereine.find { it.oepsNummer == remote.oepsNummer } + ?: Store.addVerein(remote.name, remote.oepsNummer, remote.ort ?: "") + } + } + }, + enabled = !znsState.isSyncing, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary) + ) { + if (znsState.isSyncing) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = MaterialTheme.colorScheme.onSecondary + ) + Spacer(Modifier.width(8.dp)) + Text("Synchronisiere...") + } else { + Icon(Icons.Default.CloudSync, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("ZNS-Daten-Sync") + } + } + + Column { + Text( + "ZNS-Daten geladen", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "[Version ${znsState.lastSyncVersion ?: "Kein Sync"}]", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = if (znsState.lastSyncVersion != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + ) + } + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) { + var search by remember { mutableStateOf("") } + val filteredVereine = remember(search) { + Store.vereine.filter { + it.name.contains(search, ignoreCase = true) || (it.ort?.contains(search, ignoreCase = true) + ?: false) + } + } + + Text("Oder bestehenden Veranstalter wählen:", style = MaterialTheme.typography.titleSmall) + + OutlinedTextField( + value = search, + onValueChange = { search = it }, + label = { Text("Veranstalter suchen...") }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true + ) + + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(filteredVereine) { verein -> + val isSelected = selectedVereinId == verein.id + Surface( + onClick = { onVereinSelected(verein.id) }, + shape = MaterialTheme.shapes.small, + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + border = if (isSelected) null else androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant + ) + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text(verein.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + Text( + "${verein.ort ?: ""} | ${verein.oepsNummer}", + style = MaterialTheme.typography.labelSmall + ) + } + if (isSelected) Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + + OutlinedButton( + onClick = { showVereinNeu = true }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + contentPadding = PaddingValues(12.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary) + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("+ Neuen Veranstalter anlegen", fontWeight = FontWeight.Bold) + } + } + + if (showVereinNeu) { + AlertDialog( + onDismissRequest = { showVereinNeu = false }, + title = { Text("Manueller Eintrag") }, + text = { + Box(Modifier.heightIn(max = 500.dp)) { + VeranstalterAnlegenWizard( + onCancel = { showVereinNeu = false }, + onVereinCreated = { newId -> + showVereinNeu = false + onVeranstalterCreated(newId) + } + ) + } + }, + confirmButton = {} + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun Step2Basisdaten( + titel: String, onTitelChange: (String) -> Unit, + untertitel: String, onUntertitelChange: (String) -> Unit, + von: String, onVonChange: (String) -> Unit, + bis: String, onBisChange: (String) -> Unit, + ort: String, onOrtChange: (String) -> Unit, + plz: String, onPlzChange: (String) -> Unit, + selectedDisziplinen: Set, onDisziplinenChange: (Set) -> Unit, + dateFormatter: DateTimeFormatter +) { + var showDatePickerVon by remember { mutableStateOf(false) } + var showDatePickerBis by remember { mutableStateOf(false) } + + if (showDatePickerVon) { + AppDatePickerDialog( + onDismiss = { showDatePickerVon = false }, + onDateSelected = { onVonChange(it.format(dateFormatter)) } + ) + } + if (showDatePickerBis) { + AppDatePickerDialog( + onDismiss = { showDatePickerBis = false }, + onDateSelected = { onBisChange(it.format(dateFormatter)) } + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Allgemeine Informationen", style = MaterialTheme.typography.titleMedium) + + OutlinedTextField( + value = titel, + onValueChange = onTitelChange, + label = { Text("Titel der Veranstaltung (z.B. Pfingstturnier 2026)") }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = untertitel, + onValueChange = onUntertitelChange, + label = { Text("Untertitel / Slogan (optional)") }, + modifier = Modifier.fillMaxWidth() + ) + + val dateVon = try { + LocalDate.parse(von, dateFormatter) + } catch (_: Exception) { + null + } + val dateBis = try { + LocalDate.parse(bis, dateFormatter) + } catch (_: Exception) { + null + } + val isStartInPast = dateVon != null && dateVon.isBefore(LocalDate.now()) + val daysBetween = if (dateVon != null && dateBis != null) { + java.time.temporal.ChronoUnit.DAYS.between(dateVon, dateBis) + 1 + } else null + val isOetoConform = daysBetween == null || daysBetween <= 2 + val isDateRangeInvalid = (dateVon != null && dateBis != null && dateBis.isBefore(dateVon)) || isStartInPast + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = von, + onValueChange = { }, + label = { Text("Datum von") }, + modifier = Modifier.weight(1f).clickable { showDatePickerVon = true }, + enabled = false, + isError = isStartInPast, + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledBorderColor = if (isStartInPast) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline, + disabledLabelColor = if (isStartInPast) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ), + trailingIcon = { + IconButton(onClick = { showDatePickerVon = true }) { Icon(Icons.Default.DateRange, null) } + } + ) + OutlinedTextField( + value = bis, + onValueChange = { }, + label = { Text("Datum bis") }, + modifier = Modifier.weight(1f).clickable { showDatePickerBis = true }, + enabled = false, + isError = isDateRangeInvalid, + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledBorderColor = if (isDateRangeInvalid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline, + disabledLabelColor = if (isDateRangeInvalid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ), + trailingIcon = { + IconButton(onClick = { showDatePickerBis = true }) { Icon(Icons.Default.DateRange, null) } + } + ) + } + if (isStartInPast || isDateRangeInvalid || isOetoConform.not()) { + Column { + if (isStartInPast) Text( + "Startdatum darf nicht in der Vergangenheit liegen.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall + ) + if (isDateRangeInvalid) Text( + "Enddatum darf nicht vor dem Startdatum liegen.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall + ) + if (isOetoConform.not()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(Icons.Default.Info, null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.primary) + Text( + "Hinweis: Gemäß ÖTO sind C-Turniere auf 2 Tage begrenzt.", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmall + ) + } + } + } + } + + OutlinedTextField( + value = ort, + onValueChange = onOrtChange, + label = { Text("Austragungsort (Name der Anlage / Ort)") }, + modifier = Modifier.fillMaxWidth() + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = plz, + onValueChange = { if (it.length <= 4 && it.all { char -> char.isDigit() }) onPlzChange(it) }, + label = { Text("PLZ") }, + modifier = Modifier.width(100.dp), + singleLine = true + ) + OutlinedTextField( + value = ort, + onValueChange = onOrtChange, + label = { Text("Ort") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + } + + Text("Disziplinen", style = MaterialTheme.typography.titleSmall) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val disziplinen = listOf("Springen", "Dressur", "Vielseitigkeit", "Fahren", "Voltigieren", "Reining") + disziplinen.forEach { d -> + FilterChip( + selected = d in selectedDisziplinen, + onClick = { + onDisziplinenChange(if (d in selectedDisziplinen) selectedDisziplinen - d else selectedDisziplinen + d) + }, + label = { Text(d) }, + leadingIcon = if (d in selectedDisziplinen) { + { Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp)) } + } else null + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Step3Details( + logoUrl: String, onLogoUrlChange: (String) -> Unit, + sponsorenText: String, onSponsorenTextChange: (String) -> Unit, + bewerbe: SnapshotStateList, + selectedDisziplinen: Set +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.verticalScroll(rememberScrollState())) { + Text("Branding & Partner", style = MaterialTheme.typography.titleMedium) + + OutlinedTextField( + value = logoUrl, + onValueChange = onLogoUrlChange, + label = { Text("Logo-URL oder Pfad") }, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("Optional: Link zu einem Turnierlogo") } + ) + + OutlinedTextField( + value = sponsorenText, + onValueChange = onSponsorenTextChange, + label = { Text("Sponsoren (mit Komma trennen)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3 + ) + + Text("Vorschau Sponsoren:", style = MaterialTheme.typography.labelMedium, color = Color.Gray) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + sponsorenText.split(",").filter { it.isNotBlank() }.forEach { sponsor -> + SuggestionChip(onClick = {}, label = { Text(sponsor.trim()) }) + } + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + Text("Prüfungen / Bewerbe", style = MaterialTheme.typography.titleMedium) + Text("Erfassen Sie die geplanten Bewerbe gemäß ÖTO.", style = MaterialTheme.typography.bodySmall) + + if (bewerbe.isEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Box(Modifier.padding(24.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + "Noch keine Bewerbe hinzugefügt.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + bewerbe.forEachIndexed { index, b -> + ListItem( + headlineContent = { Text("Bewerb ${b.nummer}${b.abteilung?.let { " / $it" } ?: ""}: ${b.bezeichnung}") }, + supportingContent = { Text("Klasse ${b.klasse} | ${b.disziplin}") }, + trailingContent = { + IconButton(onClick = { bewerbe.removeAt(index) }) { + Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) + } + }, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface) + ) + } + } + + var newNr by remember { mutableStateOf("") } + var newAbt by remember { mutableStateOf("") } + var newKlasse by remember { mutableStateOf("") } + var newBez by remember { mutableStateOf("") } + var newDis by remember { mutableStateOf(selectedDisziplinen.firstOrNull() ?: "Springen") } + + Card( + modifier = Modifier.fillMaxWidth(), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = newNr, + onValueChange = { newNr = it }, + label = { Text("Nr.") }, + modifier = Modifier.width(60.dp), + singleLine = true + ) + OutlinedTextField( + value = newAbt, + onValueChange = { newAbt = it }, + label = { Text("Abt.") }, + modifier = Modifier.width(60.dp), + singleLine = true + ) + OutlinedTextField( + value = newBez, + onValueChange = { newBez = it }, + label = { Text("Bezeichnung") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = newKlasse, + onValueChange = { newKlasse = it }, + label = { Text("Klasse") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + OutlinedTextField( + value = newDis, + onValueChange = { newDis = it }, + label = { Text("Disziplin") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + Button( + onClick = { + if (newNr.isNotBlank() && newBez.isNotBlank()) { + bewerbe.add(BewerbData(newNr, newAbt.ifBlank { null }, newKlasse, newDis, newBez)) + newNr = ""; newAbt = ""; newBez = "" + } + }, + shape = RoundedCornerShape(8.dp) + ) { + Icon(Icons.Default.Add, null) + Text("Add") + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VeranstaltungKonfig( veranstalterId: Long = 0, onBack: () -> Unit, onSaved: (Long, Long) -> Unit, // eventId, veranstalterId - onVeranstalterCreated: (Long) -> Unit = {}, // Neuer Flow: nach Vereinsanlage ins Profil + onVeranstalterCreated: (Long) -> Unit = {}, ) { val znsImporter: ZnsImportProvider = koinInject() val znsState = znsImporter.state DesktopThemeV2 { var currentStep by remember { mutableStateOf(if (veranstalterId == 0L) 1 else 2) } - - // Step 1: Veranstalterwahl var selectedVereinId by remember { mutableStateOf(veranstalterId) } - var showVereinNeu by remember { mutableStateOf(false) } - - // Step 2: Basisdaten var titel by remember { mutableStateOf("") } var untertitel by remember { mutableStateOf("") } var von by remember { mutableStateOf("") } var bis by remember { mutableStateOf("") } var ort by remember { mutableStateOf("") } - - var showDatePickerVon by remember { mutableStateOf(false) } - var showDatePickerBis by remember { mutableStateOf(false) } - - // Step 3: Zusatzdaten + var plz by remember { mutableStateOf("") } + var selectedDisziplinen by remember { mutableStateOf(setOf("Springen")) } var logoUrl by remember { mutableStateOf("") } - var sponsorenText by remember { mutableStateOf("") } // Kommagetrennte Liste + var sponsorenText by remember { mutableStateOf("") } + val bewerbe = remember { mutableStateListOf() } val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE } - fun Long?.toLocalDate(): LocalDate? { - if (this == null) return null - return Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()).toLocalDate() - } - - if (showDatePickerVon) { - val datePickerState = rememberDatePickerState() - DatePickerDialog( - onDismissRequest = { showDatePickerVon = false }, - confirmButton = { - TextButton(onClick = { - datePickerState.selectedDateMillis.toLocalDate()?.let { - von = it.format(dateFormatter) - } - showDatePickerVon = false - }) { Text("OK") } - }, - dismissButton = { - TextButton(onClick = { showDatePickerVon = false }) { Text("Abbrechen") } - } - ) { - DatePicker(state = datePickerState) - } - } - - if (showDatePickerBis) { - val datePickerState = rememberDatePickerState() - DatePickerDialog( - onDismissRequest = { showDatePickerBis = false }, - confirmButton = { - TextButton(onClick = { - datePickerState.selectedDateMillis.toLocalDate()?.let { - bis = it.format(dateFormatter) - } - showDatePickerBis = false - }) { Text("OK") } - }, - dismissButton = { - TextButton(onClick = { showDatePickerBis = false }) { Text("Abbrechen") } - } - ) { - DatePicker(state = datePickerState) - } - } - Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - // Header & Navigation Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { IconButton(onClick = { if (currentStep > 1) { - // Wenn wir aus einem konkreten Veranstalter kommen (id > 0), - // gehen wir bei Zurück direkt ins Profil statt auf Schritt 1. - if (veranstalterId != 0L) { - onBack() - } else { - currentStep-- - } - } else { - onBack() - } - }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") - } + if (veranstalterId != 0L) onBack() else currentStep-- + } else onBack() + }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } Column { Text("Neue Veranstaltung anlegen", style = MaterialTheme.typography.headlineSmall) Text( when (currentStep) { 1 -> "Schritt 1: Veranstalter auswählen" - 2 -> "Schritt 2: Basisdaten der Veranstaltung" - 3 -> "Schritt 3: Details & Sponsoren" + 2 -> "Schritt 2: Basisdaten" + 3 -> "Schritt 3: Details & Bewerbe" else -> "" }, style = MaterialTheme.typography.bodyMedium, @@ -424,343 +864,51 @@ fun VeranstaltungKonfigV2( LinearProgressIndicator( progress = { currentStep / 3f }, modifier = Modifier.fillMaxWidth().height(4.dp), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant + color = MaterialTheme.colorScheme.primary ) Box(Modifier.weight(1f).fillMaxWidth()) { when (currentStep) { - 1 -> { - // --- SCHRITT 1: ZNS-First Daten-Akquise --- - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - "Daten-Akquise & Veranstalter", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) + 1 -> Step1Veranstalter(znsState, znsImporter, selectedVereinId, { selectedVereinId = it }, { + selectedVereinId = it; currentStep = 2 + }) - // 1. ZNS Import Bereich (Prominent) - ZnsImportWizardSection( - state = znsState, - onFileSelect = { path -> znsImporter.onFileSelected(path) }, - onStartImport = { znsImporter.startImport(mode = "LIGHT") }, - onReset = { znsImporter.reset() } - ) + 2 -> Step2Basisdaten( + titel, + { titel = it }, + untertitel, + { untertitel = it }, + von, + { von = it }, + bis, + { bis = it }, + ort, + { ort = it }, + plz, + { plz = it }, + selectedDisziplinen, + { selectedDisziplinen = it }, + dateFormatter + ) - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - // 2. Cloud Sync (Neu gemäß User-Wunsch) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp) - ) { - Button( - onClick = { - znsImporter.syncFromCloud { remoteList -> - remoteList.forEach { remote -> - StoreV2.vereine.find { it.oepsNummer == remote.oepsNummer } - ?: StoreV2.addVerein(remote.name, remote.oepsNummer, remote.ort ?: "") - } - } - }, - enabled = !znsState.isSyncing, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary) - ) { - if (znsState.isSyncing) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - color = MaterialTheme.colorScheme.onSecondary - ) - Spacer(Modifier.width(8.dp)) - Text("Synchronisiere...") - } else { - Icon(Icons.Default.CloudSync, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text("ZNS-Daten-Sync") - } - } - - Column { - Text( - "ZNS-Daten geladen", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - "[Version ${znsState.lastSyncVersion ?: "Kein Sync"}]", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - color = if (znsState.lastSyncVersion != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error - ) - } - } - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - // 3. Bestehende Veranstalter (Kompakt) - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) { - var search by remember { mutableStateOf("") } - val filteredVereine = remember(search) { - StoreV2.vereine.filter { - it.name.contains(search, ignoreCase = true) || (it.ort?.contains(search, ignoreCase = true) - ?: false) - } - } - - Text("Oder bestehenden Veranstalter wählen:", style = MaterialTheme.typography.titleSmall) - - OutlinedTextField( - value = search, - onValueChange = { search = it }, - label = { Text("Veranstalter suchen...") }, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - singleLine = true - ) - - LazyColumn( - modifier = Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(filteredVereine) { verein -> - val isSelected = selectedVereinId == verein.id - Surface( - onClick = { selectedVereinId = verein.id }, - shape = MaterialTheme.shapes.small, - color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - border = if (isSelected) null else androidx.compose.foundation.BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant - ) - ) { - Row( - Modifier.padding(horizontal = 12.dp, vertical = 8.dp).fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text(verein.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) - Text( - "${verein.ort ?: ""} | ${verein.oepsNummer}", - style = MaterialTheme.typography.labelSmall - ) - } - if (isSelected) Icon( - Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } - } - } - } - - // 3. Manueller Button für neuen Veranstalter - OutlinedButton( - onClick = { showVereinNeu = true }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - contentPadding = PaddingValues(12.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary) - ) { - Icon(Icons.Default.Add, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text("+ Neuen Veranstalter anlegen", fontWeight = FontWeight.Bold) - } - } - - if (showVereinNeu) { - AlertDialog( - onDismissRequest = { showVereinNeu = false }, - title = { Text("Manueller Eintrag") }, - text = { - Box(Modifier.heightIn(max = 500.dp)) { - VeranstalterAnlegenWizard( - onCancel = { showVereinNeu = false }, - onVereinCreated = { newId -> - showVereinNeu = false - selectedVereinId = newId - currentStep = 2 // Direkt zum nächsten Schritt - } - ) - } - }, - confirmButton = {} - ) - } - } - } - - 2 -> { - // --- SCHRITT 2: Basisdaten --- - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Allgemeine Informationen", style = MaterialTheme.typography.titleMedium) - - OutlinedTextField( - value = titel, - onValueChange = { titel = it }, - label = { Text("Titel der Veranstaltung (z.B. Pfingstturnier 2026)") }, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = untertitel, - onValueChange = { untertitel = it }, - label = { Text("Untertitel / Slogan (optional)") }, - modifier = Modifier.fillMaxWidth() - ) - - val dateVon = try { - LocalDate.parse(von, dateFormatter) - } catch (_: Exception) { - null - } - val dateBis = try { - LocalDate.parse(bis, dateFormatter) - } catch (_: Exception) { - null - } - val today = LocalDate.now() - val isStartInPast = dateVon != null && dateVon.isBefore(today) - - val daysBetween = if (dateVon != null && dateBis != null) { - java.time.temporal.ChronoUnit.DAYS.between(dateVon, dateBis) + 1 - } else null - - val isOetoConform = daysBetween == null || daysBetween <= 2 - - val isDateRangeInvalid = - (dateVon != null && dateBis != null && dateBis.isBefore(dateVon)) || isStartInPast - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = von, - onValueChange = { /* Schreibgeschützt, via Picker */ }, - label = { Text("Datum von") }, - modifier = Modifier.weight(1f).clickable { showDatePickerVon = true }, - enabled = false, - isError = isStartInPast, - colors = OutlinedTextFieldDefaults.colors( - disabledTextColor = MaterialTheme.colorScheme.onSurface, - 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 - ), - trailingIcon = { - IconButton(onClick = { showDatePickerVon = true }) { - Icon(Icons.Default.DateRange, contentDescription = "Datum wählen") - } - }, - supportingText = { - if (isStartInPast) { - Text("Startdatum darf nicht in der Vergangenheit liegen.") - } - } - ) - OutlinedTextField( - value = bis, - onValueChange = { /* Schreibgeschützt, via Picker */ }, - label = { Text("Datum bis") }, - modifier = Modifier.weight(1f).clickable { showDatePickerBis = true }, - enabled = false, - isError = isDateRangeInvalid, - colors = OutlinedTextFieldDefaults.colors( - disabledTextColor = MaterialTheme.colorScheme.onSurface, - disabledBorderColor = if (isDateRangeInvalid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline, - disabledLabelColor = if (isDateRangeInvalid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - trailingIcon = { - IconButton(onClick = { showDatePickerBis = true }) { - Icon(Icons.Default.DateRange, contentDescription = "Datum wählen") - } - }, - supportingText = { - Column { - if (isDateRangeInvalid) { - Text("Enddatum darf nicht vor dem Startdatum liegen.") - } - if (isOetoConform.not()) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - Icons.Default.Info, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - "Hinweis: Gemäß ÖTO sind C-Turniere auf 2 Tage begrenzt.", - color = MaterialTheme.colorScheme.primary - ) - } - } - } - } - ) - } - - OutlinedTextField( - value = ort, - onValueChange = { ort = it }, - label = { Text("Austragungsort (falls abweichend vom Vereinssitz)") }, - modifier = Modifier.fillMaxWidth() - ) - } - } - - 3 -> { - // --- SCHRITT 3: Details & Sponsoren --- - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Branding & Partner", style = MaterialTheme.typography.titleMedium) - - OutlinedTextField( - value = logoUrl, - onValueChange = { logoUrl = it }, - label = { Text("Logo-URL oder Pfad") }, - modifier = Modifier.fillMaxWidth(), - supportingText = { Text("Optional: Link zu einem Turnierlogo") } - ) - - OutlinedTextField( - value = sponsorenText, - onValueChange = { sponsorenText = it }, - label = { Text("Sponsoren (mit Komma trennen)") }, - modifier = Modifier.fillMaxWidth(), - minLines = 3 - ) - - Spacer(Modifier.height(16.dp)) - Text( - "Vorschau Sponsoren:", - style = MaterialTheme.typography.labelMedium, - color = Color.Gray - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - sponsorenText.split(",").filter { it.isNotBlank() }.forEach { sponsor -> - SuggestionChip(onClick = {}, label = { Text(sponsor.trim()) }) - } - } - } - } + 3 -> Step3Details( + logoUrl, + { logoUrl = it }, + sponsorenText, + { sponsorenText = it }, + bewerbe, + selectedDisziplinen + ) } } - // Footer Navigation Row( - modifier = Modifier.fillMaxWidth(), + Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - if (currentStep > 1) { - OutlinedButton(onClick = { currentStep-- }) { - Text("Zurück") - } - } else { - Spacer(Modifier.width(1.dp)) - } + if (currentStep > 1) OutlinedButton(onClick = { currentStep-- }) { Text("Zurück") } + else Spacer(Modifier.width(1.dp)) var showConfirm by remember { mutableStateOf(false) } if (showConfirm) { @@ -769,55 +917,30 @@ fun VeranstaltungKonfigV2( confirmButton = { TextButton(onClick = { val id = System.currentTimeMillis() - val v = VeranstaltungV2( + val v = Veranstaltung( id = id, veranstalterId = selectedVereinId, titel = titel.trim(), datumVon = von.trim(), datumBis = bis.trim().ifBlank { null }, untertitel = untertitel.trim(), - ort = ort.trim().ifBlank { StoreV2.vereine.find { it.id == selectedVereinId }?.ort ?: "" }, + ort = ort.trim().ifBlank { Store.vereine.find { it.id == selectedVereinId }?.ort ?: "" }, logoUrl = logoUrl.trim().ifBlank { null } ) sponsorenText.split(",").filter { it.isNotBlank() }.forEach { v.sponsoren.add(it.trim()) } - StoreV2.addEventFirst(selectedVereinId, v) + Store.addEventFirst(selectedVereinId, v) showConfirm = false onSaved(id, selectedVereinId) }) { Text("Anlegen") } }, dismissButton = { TextButton(onClick = { showConfirm = false }) { Text("Abbrechen") } }, title = { Text("Veranstaltung final anlegen?") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Bitte die Daten prüfen. Für diese Veranstaltung wird eine eigene Datenbank initialisiert.") - HorizontalDivider() - val titelText = titel.trim() - val untertitelText = untertitel.trim().ifBlank { "-" } - val vonText = von.trim() - val bisText = bis.trim() - val zeitraumText = if (bisText.isNotEmpty()) "$vonText – $bisText" else vonText - val vName = StoreV2.vereine.find { it.id == selectedVereinId }?.name ?: "#$selectedVereinId" - val spons = sponsorenText.split(',').map { it.trim() }.filter { it.isNotEmpty() } - - Text("Titel: $titelText") - Text("Untertitel: $untertitelText") - Text("Zeitraum: $zeitraumText") - Text("Veranstalter: $vName") - if (logoUrl.isNotBlank()) Text("Logo: ${logoUrl.trim()}") - if (spons.isNotEmpty()) Text("Sponsoren: ${spons.joinToString(", ")}") - } - } + text = { Text("Bitte die Daten prüfen. Titel: ${titel.trim()}, Veranstalter: ${Store.vereine.find { it.id == selectedVereinId }?.name ?: ""}") } ) } Button( - onClick = { - if (currentStep < 3) { - currentStep++ - } else { - showConfirm = true - } - }, + onClick = { if (currentStep < 3) currentStep++ else showConfirm = true }, enabled = when (currentStep) { 1 -> selectedVereinId != 0L 2 -> { @@ -831,24 +954,21 @@ fun VeranstaltungKonfigV2( } catch (_: Exception) { null } - val today2 = LocalDate.now() - val startInPast = dVon != null && dVon.isBefore(today2) - val rangeInvalid = (dVon != null && dBis != null && dBis.isBefore(dVon)) || startInPast + val rangeInvalid = + (dVon != null && dBis != null && dBis.isBefore(dVon)) || (dVon != null && dVon.isBefore(LocalDate.now())) titel.isNotBlank() && von.isNotBlank() && !rangeInvalid } - 3 -> true + 3 -> bewerbe.isNotEmpty() else -> false } - ) { - Text(if (currentStep == 3) "Veranstaltung final anlegen" else "Weiter") - } + ) { Text(if (currentStep == 3) "Veranstaltung final anlegen" else "Weiter") } } } } } -data class TurnierV2( +data class Turnier( val id: Long, val veranstaltungId: Long, val turnierNr: Int, @@ -864,15 +984,17 @@ data class TurnierV2( var sponsoren: SnapshotStateList = mutableStateListOf(), ) -object TurnierStoreV2 { - private val map = mutableMapOf>() - fun list(veranstaltungId: Long): MutableList = map.getOrPut(veranstaltungId) { mutableListOf() } - fun add(veranstaltungId: Long, t: TurnierV2) { list(veranstaltungId).add(0, t) } +object TurnierStore { + private val map = mutableMapOf>() + fun list(veranstaltungId: Long): MutableList = map.getOrPut(veranstaltungId) { mutableListOf() } + fun add(veranstaltungId: Long, t: Turnier) { + list(veranstaltungId).add(0, t) + } fun remove(veranstaltungId: Long, tId: Long) { list(veranstaltungId).removeAll { it.id == tId } } // Hilfsmethode für Reflection-Zugriff aus anderen Modulen (StammdatenTab) @JvmStatic - fun allTurniere(): List = map.values.flatten() + fun allTurniere(): List = map.values.flatten() } @Composable @@ -885,8 +1007,8 @@ fun VeranstaltungProfilScreen( onNavigateToVeranstalterProfil: (Long) -> Unit, ) { DesktopThemeV2 { - val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId } - val turniere = remember(veranstaltungId) { TurnierStoreV2.list(veranstaltungId) } + val veranstaltung = Store.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId } + val turniere = remember(veranstaltungId) { TurnierStore.list(veranstaltungId) } Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) { // Header @@ -987,7 +1109,7 @@ fun VeranstaltungProfilScreen( TurnierCard( turnier = t, onOpen = { onTurnierOpen(t.id) }, - onDelete = { TurnierStoreV2.remove(veranstaltungId, t.id) } + onDelete = { TurnierStore.remove(veranstaltungId, t.id) } ) } } @@ -1028,7 +1150,7 @@ private fun KpiCard( @Composable private fun TurnierCard( - turnier: TurnierV2, + turnier: Turnier, onOpen: () -> Unit, onDelete: () -> Unit, ) { @@ -1100,14 +1222,14 @@ private fun TurnierCard( } @Composable -fun TurnierWizardV2( +fun TurnierWizard( veranstalterId: Long, veranstaltungId: Long, onBack: () -> Unit, onSaved: (Long) -> Unit, ) { DesktopThemeV2 { - val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId } + val veranstaltung = Store.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId } var currentStep by remember { mutableStateOf(1) } var showZnsDialog by remember { mutableStateOf(false) } @@ -1222,7 +1344,7 @@ fun TurnierWizardV2( currentStep++ } else { val id = System.currentTimeMillis() - val newTurnier = TurnierV2( + val newTurnier = Turnier( id = id, veranstaltungId = veranstaltungId, turnierNr = nr.toInt(), @@ -1238,7 +1360,7 @@ fun TurnierWizardV2( newTurnier.kategorie.addAll(kat) newTurnier.sponsoren.addAll(sponsoren) - TurnierStoreV2.add(veranstaltungId, newTurnier) + TurnierStore.add(veranstaltungId, newTurnier) onSaved(id) } }, @@ -1381,7 +1503,7 @@ private fun Step2Sparten( kat: SnapshotStateList, von: String, onVonChange: (String) -> Unit, bis: String, onBisChange: (String) -> Unit, - veranstaltung: VeranstaltungV2? + veranstaltung: Veranstaltung? ) { var showDatePickerVon by remember { mutableStateOf(false) } var showDatePickerBis by remember { mutableStateOf(false) }