From 43d83e403af035504679d748ec63935738960ed1 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Sat, 28 Mar 2026 02:08:14 +0100 Subject: [PATCH] Implement Vision_03: Overhaul Veranstalter and Veranstaltung flow with new composables, centralized state management via StoreV2, and updated navigation logic. Add DesktopThemeV2 for consistent UI styling. --- .../screens/layout/DesktopMainLayout.kt | 98 ++++----- .../kotlin/at/mocode/desktop/v2/Screens.kt | 159 ++++++++++++++ .../kotlin/at/mocode/desktop/v2/Stores.kt | 42 ++++ .../kotlin/at/mocode/desktop/v2/Theme.kt | 35 ++++ .../mocode/desktop/v2/VeranstaltungScreens.kt | 198 ++++++++++++++++++ 5 files changed, 474 insertions(+), 58 deletions(-) create mode 100644 frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt create mode 100644 frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt create mode 100644 frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Theme.kt create mode 100644 frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt 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 158384b5..bb19c548 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 @@ -293,27 +293,28 @@ private fun DesktopContentArea( // Onboarding ohne Login is AppScreen.Onboarding -> { val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject() - at.mocode.desktop.screens.onboarding.OnboardingScreen( - onContinue = { _, _, _ -> - // Dummy-Token setzen, damit nach Onboarding der Admin-Flow sichtbar ist - authTokenManager.setToken("dummy.jwt.token") - onNavigate(AppScreen.Veranstaltungen) - } + // V2 Onboarding (Vision_03) + at.mocode.desktop.v2.OnboardingScreenV2 { _, _ -> + authTokenManager.setToken("dummy.jwt.token") + onNavigate(AppScreen.VeranstalterAuswahl) + } + } + + // Root-Screen: Leitet in V2-Fluss + is AppScreen.Veranstaltungen -> { + // Direkt zur Veranstalter-Auswahl V2 + at.mocode.desktop.v2.VeranstalterAuswahlV2( + onBack = { /* bleibt root */ }, + onWeiter = { vId -> onNavigate(AppScreen.VeranstalterDetail(vId)) }, + onNeu = { onNavigate(AppScreen.VeranstalterNeu) }, ) } - // Root-Screen: Admin-Übersicht - is AppScreen.Veranstaltungen -> AdminUebersichtScreen( - onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) }, - onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) }, - onPingService = { onNavigate(AppScreen.Ping) }, - ) - // Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht - is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahlScreen( - onZurueck = { onNavigate(AppScreen.Veranstaltungen) }, + is AppScreen.VeranstalterAuswahl -> at.mocode.desktop.v2.VeranstalterAuswahlV2( + onBack = { onNavigate(AppScreen.Veranstaltungen) }, onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) }, - onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) }, + onNeu = { onNavigate(AppScreen.VeranstalterNeu) }, ) is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen( @@ -328,74 +329,50 @@ private fun DesktopContentArea( onBack = { onNavigate(AppScreen.VeranstalterAuswahl) } ) } else { - VeranstalterDetailScreen( + at.mocode.desktop.v2.VeranstalterDetailV2( veranstalterId = vId, - onZurueck = { onNavigate(AppScreen.VeranstalterAuswahl) }, - onVeranstaltungOeffnen = { evtId -> - onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) - }, - onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungKonfig(vId)) }, + onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }, + onZurVeranstaltung = { evtId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) }, + onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig(vId)) }, ) } } is AppScreen.VeranstaltungKonfig -> { val vId = currentScreen.veranstalterId - if (!FakeVeranstalterStore.exists(vId)) { + // V2: Validierung über StoreV2 + if (at.mocode.desktop.v2.StoreV2.vereine.none { it.id == vId }) { InvalidContextNotice( message = "Veranstalter (ID=$vId) nicht gefunden.", onBack = { onNavigate(AppScreen.VeranstalterAuswahl) } ) } else { - at.mocode.veranstalter.feature.presentation.VeranstaltungKonfigScreen( + at.mocode.desktop.v2.VeranstaltungKonfigV2( veranstalterId = vId, - onAbbrechen = { onNavigate(AppScreen.VeranstalterDetail(vId)) }, - onSpeichern = { titel, datumVon, datumBis -> - // ID generieren und in den Fake-Store einfügen - val id = System.currentTimeMillis() - at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore.addFirst( - vId, - at.mocode.veranstalter.feature.presentation.VeranstaltungListUiModel( - id = id, - name = titel, - datum = if (datumBis.isNotBlank()) "$datumVon – $datumBis" else datumVon, - ort = "", - turnierAnzahl = 1, - nennungen = 0, - bewerbe = 0, - letzteAktivitaet = "soeben", - status = at.mocode.frontend.core.designsystem.models.VeranstaltungStatus.VORBEREITUNG, - ) - ) - onNavigate(AppScreen.VeranstalterDetail(vId)) - } + onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) }, + onSaved = { evtId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) } ) } } is AppScreen.VeranstaltungUebersicht -> { val vId = currentScreen.veranstalterId val evtId = currentScreen.veranstaltungId - if (!FakeVeranstalterStore.exists(vId)) { + if (at.mocode.desktop.v2.StoreV2.vereine.none { it.id == vId }) { InvalidContextNotice( message = "Veranstalter (ID=$vId) nicht gefunden.", onBack = { onNavigate(AppScreen.VeranstalterAuswahl) } ) - } else if (!FakeVeranstaltungStore.belongsTo(vId, evtId)) { + } else if (at.mocode.desktop.v2.StoreV2.eventsFor(vId).none { it.id == evtId }) { InvalidContextNotice( message = "Veranstaltung (ID=$evtId) gehört nicht zu Veranstalter #$vId.", onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) } ) } else { - VeranstaltungUebersichtScreen( + at.mocode.desktop.v2.VeranstaltungUebersichtV2( veranstalterId = vId, veranstaltungId = evtId, - onZurueck = { onNavigate(AppScreen.VeranstalterDetail(vId)) }, - onTurnierOeffnen = { tId -> - onNavigate(AppScreen.TurnierDetail(evtId, tId)) - }, + onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) }, onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(evtId)) }, - onZnsImport = { /* TODO */ }, - onDbImport = { /* TODO */ }, - onDbExport = { /* TODO */ }, + onTurnierOpen = { tId -> onNavigate(AppScreen.TurnierDetail(evtId, tId)) }, ) } } @@ -430,16 +407,21 @@ private fun DesktopContentArea( } is AppScreen.TurnierNeu -> { val evtId = currentScreen.veranstaltungId - if (!FakeVeranstaltungStore.exists(evtId)) { + // 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 } + } + if (parent == null) { InvalidContextNotice( message = "Veranstaltung (ID=$evtId) nicht gefunden.", onBack = { onNavigate(AppScreen.Veranstaltungen) } ) } else { - TurnierNeuScreen( + at.mocode.desktop.v2.TurnierWizardV2( + veranstalterId = parent.id, veranstaltungId = evtId, - onBack = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) }, - onSave = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) }, + onBack = { 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/Screens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt new file mode 100644 index 00000000..53c854b6 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt @@ -0,0 +1,159 @@ +package at.mocode.desktop.v2 + +import androidx.compose.foundation.background +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.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +@Composable +fun OnboardingScreenV2(onContinue: (String, String) -> Unit) { + DesktopThemeV2 { + Surface(color = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Onboarding", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + var geraetName by remember { mutableStateOf("") } + var key by remember { mutableStateOf("") } + var showPw by remember { mutableStateOf(false) } + + OutlinedTextField( + value = geraetName, + onValueChange = { geraetName = it }, + label = { Text("Gerätename (Pflicht)") }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = key, + onValueChange = { key = it }, + label = { Text("Sicherheitsschlüssel (Pflicht)") }, + trailingIcon = { + IconButton(onClick = { showPw = !showPw }) { + Icon(if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = null) + } + }, + visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + + val enabled = geraetName.trim().length >= 3 && key.trim().length >= 8 + Button(onClick = { onContinue(geraetName, key) }, enabled = enabled) { + Text("Weiter zum Veranstalter‑Flow") + } + if (!enabled) Text("Mind. 3 Zeichen für Namen und 8 Zeichen für Schlüssel", color = Color(0xFFB00020)) + } + } + } +} + +@Composable +fun VeranstalterAuswahlV2( + onBack: () -> Unit, + onWeiter: (Long) -> Unit, + onNeu: () -> Unit, +) { + DesktopThemeV2 { + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) + Text("Veranstalter auswählen", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = onNeu) { Text("+ Neuer Veranstalter") } + } + + var selectedId by remember { mutableStateOf(null) } + + LazyColumn(Modifier.fillMaxSize()) { + items(StoreV2.vereine) { v -> + val sel = selectedId == v.id + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .clickable { + selectedId = v.id + // Direktnavigation beim Klick auf eine Karte + onWeiter(v.id) + }, + border = if (sel) ButtonDefaults.outlinedButtonBorder else null, + ) { + Column(Modifier.padding(12.dp)) { + Text(v.name, fontWeight = FontWeight.SemiBold) + Text("OEPS: ${v.oepsNummer} · ${v.ort}", color = Color(0xFF6B7280)) + } + } + } + } + + Button(onClick = { selectedId?.let(onWeiter) }, enabled = selectedId != null) { Text("Weiter zum Veranstalter") } + } + } +} + +@Composable +fun VeranstalterDetailV2( + veranstalterId: Long, + onBack: () -> Unit, + onZurVeranstaltung: (Long) -> Unit, + onNeuVeranstaltung: () -> Unit, +) { + DesktopThemeV2 { + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) + val verein = StoreV2.vereine.firstOrNull { it.id == veranstalterId } + Text(verein?.name ?: "Veranstalter", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.weight(1f)) + Button(onClick = onNeuVeranstaltung) { Text("+ Neue Veranstaltung") } + } + + val events = StoreV2.eventsFor(veranstalterId) + if (events.isEmpty()) Text("Noch keine Veranstaltungen angelegt.", color = Color(0xFF6B7280)) + + LazyColumn(Modifier.fillMaxSize()) { + items(events) { evt -> + Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(evt.titel, fontWeight = FontWeight.SemiBold) + Text("${evt.datumVon}${evt.datumBis?.let { " – $it" } ?: ""}", color = Color(0xFF6B7280)) + AssistChip(onClick = {}, label = { Text(evt.status) }) + } + Button(onClick = { onZurVeranstaltung(evt.id) }) { Text("Zur Veranstaltung") } + Spacer(Modifier.width(8.dp)) + var confirm by remember { mutableStateOf(false) } + if (confirm) { + AlertDialog( + onDismissRequest = { confirm = false }, + confirmButton = { + TextButton(onClick = { + StoreV2.removeEvent(veranstalterId, evt.id) + confirm = false + }) { Text("Löschen") } + }, + dismissButton = { TextButton(onClick = { confirm = false }) { Text("Abbrechen") } }, + title = { Text("Veranstaltung löschen?") }, + text = { Text("Diese Aktion entfernt die Veranstaltung und alle zugehörigen Turniere im Prototypen.") } + ) + } + IconButton(onClick = { confirm = true }) { Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) } + } + } + } + } + } + } +} 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 new file mode 100644 index 00000000..c47e0d5b --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt @@ -0,0 +1,42 @@ +package at.mocode.desktop.v2 + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList + +data class Verein( + val id: Long, + val name: String, + val oepsNummer: String, + val ort: String, +) + +data class VeranstaltungV2( + val id: Long, + val veranstalterId: Long, + var titel: String, + var datumVon: String, + var datumBis: String?, + var status: String = "In Vorbereitung", +) + +object StoreV2 { + val vereine: SnapshotStateList = mutableStateListOf( + Verein(1, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."), + Verein(2, "Pferdesportverein Linz", "V-OOE-0002", "Linz"), + ) + + private val veranstaltungen: MutableMap> = mutableMapOf() + + fun eventsFor(vereinId: Long): SnapshotStateList = + veranstaltungen.getOrPut(vereinId) { mutableStateListOf() } + + fun addEventFirst(vereinId: Long, v: VeranstaltungV2) { + eventsFor(vereinId).add(0, v) + } + + fun removeEvent(vereinId: Long, veranstaltungId: Long) { + val list = eventsFor(vereinId) + val idx = list.indexOfFirst { it.id == veranstaltungId } + if (idx >= 0) list.removeAt(idx) + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Theme.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Theme.kt new file mode 100644 index 00000000..43920664 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Theme.kt @@ -0,0 +1,35 @@ +package at.mocode.desktop.v2 + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +// Vision_03 Farbschema (vereinfacht) +private val PrimaryBlue = Color(0xFF1E3A8A) +private val AccentBlue = Color(0xFF3B82F6) +private val Surface = Color(0xFFFFFFFF) +private val Background = Color(0xFFF7F8FA) + +private val LightColors: ColorScheme = lightColorScheme( + primary = PrimaryBlue, + secondary = AccentBlue, + background = Background, + surface = Surface, + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = Color(0xFF0F172A), + onSurface = Color(0xFF111827), +) + +@Composable +fun DesktopThemeV2(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = LightColors, + typography = Typography(), + content = content, + ) +} 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 new file mode 100644 index 00000000..a5ac433c --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt @@ -0,0 +1,198 @@ +package at.mocode.desktop.v2 + +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.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun VeranstaltungKonfigV2( + veranstalterId: Long, + onBack: () -> Unit, + onSaved: (Long) -> Unit, +) { + DesktopThemeV2 { + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) + Text("Neue Veranstaltung", style = MaterialTheme.typography.titleLarge) + } + + var titel by remember { mutableStateOf("") } + var von by remember { mutableStateOf("") } + var bis by remember { mutableStateOf("") } + + OutlinedTextField(value = titel, onValueChange = { titel = it }, label = { Text("Titel (Pflicht)") }, modifier = Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = von, onValueChange = { von = it }, label = { Text("von (YYYY-MM-DD)") }, modifier = Modifier.weight(1f)) + OutlinedTextField(value = bis, onValueChange = { bis = it }, label = { Text("bis (YYYY-MM-DD)") }, modifier = Modifier.weight(1f)) + } + + val validDates = von.isNotBlank() && (bis.isBlank() || bis >= von) + if (!validDates) Text("bis-Datum darf nicht vor von-Datum liegen", color = Color(0xFFB00020)) + val enabled = titel.trim().isNotEmpty() && validDates + + Button(onClick = { + val id = System.currentTimeMillis() + StoreV2.addEventFirst(veranstalterId, VeranstaltungV2(id, veranstalterId, titel.trim(), von.trim(), bis.trim().ifBlank { null })) + onSaved(id) + }, enabled = enabled) { Text("Speichern") } + } + } +} + +data class TurnierV2( + val id: Long, + val veranstaltungId: Long, + val turnierNr: Int, + var kategorie: String, + var datumVon: String, + var datumBis: String?, +) + +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) } + fun remove(veranstaltungId: Long, tId: Long) { list(veranstaltungId).removeAll { it.id == tId } } +} + +@Composable +fun VeranstaltungUebersichtV2( + veranstalterId: Long, + veranstaltungId: Long, + onBack: () -> Unit, + onTurnierNeu: () -> Unit, + onTurnierOpen: (Long) -> Unit, +) { + 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.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) + Text(veranstaltung?.titel ?: "Veranstaltung", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.weight(1f)) + Button(onClick = onTurnierNeu) { Text("+ Neues Turnier") } + } + + if (veranstaltung != null) { + Text("Zeitraum: ${veranstaltung.datumVon}${veranstaltung.datumBis?.let { " – $it" } ?: ""}") + Text("Status: ${veranstaltung.status}") + } + + val list = remember(veranstaltungId) { TurnierStoreV2.list(veranstaltungId) } + if (list.isEmpty()) Text("Noch keine Turniere angelegt.", color = Color(0xFF6B7280)) + + 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)) } + } + } + } + } + } + } +} + +@Composable +fun TurnierWizardV2( + veranstalterId: Long, + veranstaltungId: Long, + onBack: () -> Unit, + onSaved: (Long) -> Unit, +) { + 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.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) + Text("Neues Turnier", style = MaterialTheme.typography.titleLarge) + } + + 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 ?: "") } + + 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()) + + 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 -> "" + } + 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) + } + DropdownMenu(expanded = katMenu, onDismissRequest = { katMenu = false }) { + kategorien.forEach { k -> + DropdownMenuItem(onClick = { kat = k; katMenu = false }, text = { Text(k) }) + } + } + } + } + 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") } + } + } +}