From a35dfa1434a4b94fe2fa4339873be0806182d881 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Tue, 21 Apr 2026 17:01:45 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20f=C3=BCge=20Event-Wizard-Screen=20und?= =?UTF-8?q?=20Schritt-Logik=20f=C3=BCr=20neue=20Veranstaltungen=20hinzu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: StefanMoCoAt --- .../feature/wizard/EventWizardFlow.kt | 7 + .../feature/presentation/EventWizardScreen.kt | 534 ++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 frontend/features/veranstaltung-feature/src/commonMain/kotlin/at/mocode/veranstaltung/feature/wizard/EventWizardFlow.kt create mode 100644 frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardScreen.kt diff --git a/frontend/features/veranstaltung-feature/src/commonMain/kotlin/at/mocode/veranstaltung/feature/wizard/EventWizardFlow.kt b/frontend/features/veranstaltung-feature/src/commonMain/kotlin/at/mocode/veranstaltung/feature/wizard/EventWizardFlow.kt new file mode 100644 index 00000000..0722529f --- /dev/null +++ b/frontend/features/veranstaltung-feature/src/commonMain/kotlin/at/mocode/veranstaltung/feature/wizard/EventWizardFlow.kt @@ -0,0 +1,7 @@ +package at.mocode.veranstaltung.feature.wizard + +// Platzhalter für den Event-Flow. +// Hinweis: Der echte Flow lebt zunächst als Demo in :frontend:core:wizard (samples), +// bis die VM-Delegation hinter dem Feature-Flag integriert wird. + +object EventWizardPlaceholder diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardScreen.kt new file mode 100644 index 00000000..517a09b6 --- /dev/null +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/EventWizardScreen.kt @@ -0,0 +1,534 @@ +package at.mocode.veranstaltung.feature.presentation + +import androidx.compose.foundation.background +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import at.mocode.frontend.core.designsystem.components.MsFilePicker +import at.mocode.frontend.core.designsystem.components.MsTextField +import at.mocode.frontend.core.designsystem.theme.Dimens +import at.mocode.frontend.features.turnier.presentation.TurnierWizard +import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScreen +import kotlin.uuid.ExperimentalUuidApi + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) +@Composable +fun EventWizardScreen( + viewModel: EventWizardViewModel, + onBack: () -> Unit, + onFinish: () -> Unit, + onNavigateToVeranstalterNeu: () -> Unit = {} +) { + val state = viewModel.state + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { Text("Neues Event anlegen") }, + navigationIcon = { + IconButton(onClick = { + if (state.currentStep == WizardStep.ZNS_CHECK) onBack() + else viewModel.previousStep() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") + } + } + ) + LinearProgressIndicator( + progress = { (state.currentStep.ordinal + 1).toFloat() / WizardStep.entries.size.toFloat() }, + modifier = Modifier.fillMaxWidth() + ) + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // Sticky Preview Card + VorschauCard(state = state) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(Dimens.SpacingL) + .verticalScroll(rememberScrollState()) + ) { + when (state.currentStep) { + WizardStep.ZNS_CHECK -> ZnsCheckStep(viewModel) + WizardStep.VERANSTALTER_SELECTION -> VeranstalterSelectionStep(viewModel, onNavigateToVeranstalterNeu) + WizardStep.ANSPRECHPERSON_MAPPING -> AnsprechpersonMappingStep(viewModel) + WizardStep.META_DATA -> MetaDataStep(viewModel) + WizardStep.TURNIER_ANLAGE -> TurnierAnlageStep(viewModel) + WizardStep.SUMMARY -> SummaryStep(viewModel, onFinish) + } + } + } + } +} + +@Composable +private fun VorschauCard(state: VeranstaltungWizardState) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(Dimens.SpacingM), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier.padding(Dimens.SpacingM), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM) + ) { + // Placeholder für Logo + Box( + modifier = Modifier + .size(64.dp) + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.small), + contentAlignment = Alignment.Center + ) { + Text("LOGO", style = MaterialTheme.typography.labelSmall) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.name.ifBlank { "Neues Event" }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = state.veranstalterName.ifBlank { "Kein Veranstalter gewählt" }, + style = MaterialTheme.typography.bodyMedium + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = state.ort.ifBlank { "Ort noch nicht festgelegt" }, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "| ${state.startDatum ?: ""} - ${state.endDatum ?: ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (state.ansprechpersonName.isNotBlank()) { + Text( + text = "Ansprechperson: ${state.ansprechpersonName} (${state.ansprechpersonSatznummer})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun ZnsCheckStep(viewModel: EventWizardViewModel) { + val state = viewModel.state + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 1: Stammdaten-Verfügbarkeit prüfen", style = MaterialTheme.typography.titleLarge) + + // Stats Anzeige + state.stammdatenStats?.let { stats -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Stammdaten-Status", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Letzter Import:") + Text(stats.lastImport ?: "Nie", fontWeight = FontWeight.Medium) + } + HorizontalDivider( + modifier = Modifier.alpha(0.5f), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Vereine:") + Text("${stats.vereinCount}", fontWeight = FontWeight.Medium) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Reiter:") + Text("${stats.reiterCount}", fontWeight = FontWeight.Medium) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Pferde:") + Text("${stats.pferdCount}", fontWeight = FontWeight.Medium) + } + } + } + } + + if (!state.isZnsAvailable && !state.isCheckingStats) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Warning, null, tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.width(12.dp)) + Column { + Text( + "🚨 Stammdaten fehlen!", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Text("Bitte importieren Sie die aktuelle ZNS.zip, um fortzufahren.") + } + } + } + + // Plug-and-Play Integration des Importers + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + .padding(vertical = 8.dp) + ) { + StammdatenImportScreen(onBack = {}) + } + + Button( + onClick = { viewModel.checkStammdatenStatus() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Refresh, null) + Spacer(Modifier.width(8.dp)) + Text("Import-Status aktualisieren") + } + } + } + + if (state.isZnsAvailable) { + Button( + onClick = { viewModel.nextStep() }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Weiter zur Veranstalter-Wahl") + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) +@Composable +private fun VeranstalterSelectionStep( + viewModel: EventWizardViewModel, + onNavigateToVeranstalterNeu: () -> Unit +) { + var searchQuery by remember { mutableStateOf("") } + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 2: Veranstalter auswählen", style = MaterialTheme.typography.titleLarge) + Text("Suchen Sie nach dem Verein (Name oder OEPS-Nummer).") + + MsTextField( + value = searchQuery, + onValueChange = { + searchQuery = it + if (it.length >= 3) { + viewModel.searchVeranstalterByOepsNr(it) + } + }, + label = "Verein suchen (z.B. 6-009)", + placeholder = "OEPS-Nummer eingeben...", + modifier = Modifier.fillMaxWidth() + ) + + if (viewModel.state.veranstalterId != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon(Icons.Default.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) + Column(modifier = Modifier.weight(1f)) { + Text( + viewModel.state.veranstalterName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text("OEPS-Nr: ${viewModel.state.veranstalterVereinsNummer}") + } + Button(onClick = { viewModel.nextStep() }) { + Text("Auswählen & Weiter") + } + } + } + } + + if (viewModel.state.znsSearchResults.isNotEmpty()) { + Text("Gefundene Vereine in den Stammdaten:", style = MaterialTheme.typography.labelMedium) + viewModel.state.znsSearchResults.forEach { znsVerein -> + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { viewModel.selectZnsVerein(znsVerein) } + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(Icons.Default.Add, null) + Column { + Text(znsVerein.name, fontWeight = FontWeight.Medium) + Text("OEPS-Nr: ${znsVerein.oepsNummer} | ${znsVerein.ort ?: ""}", style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + + if (viewModel.state.veranstalterId == null && viewModel.state.znsSearchResults.isEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Geben Sie mindestens 3 Zeichen der OEPS-Nummer ein, um die Stammdaten zu durchsuchen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + + Text("Verein nicht gefunden?", style = MaterialTheme.typography.labelLarge) + + Button( + onClick = onNavigateToVeranstalterNeu, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary) + ) { + Icon(Icons.Default.Add, null) + Spacer(Modifier.width(8.dp)) + Text("Diesen Verein als neuen Veranstalter anlegen") + } + + // Fallback/Demo Button + OutlinedButton( + onClick = { viewModel.searchVeranstalterByOepsNr("6-009") } + ) { + Text("Beispiel: 6-009 suchen") + } + } + } + } +} + +@Composable +private fun AnsprechpersonMappingStep(viewModel: EventWizardViewModel) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 3: Ansprechperson festlegen", style = MaterialTheme.typography.titleLarge) + Text("Wer ist für diese Veranstaltung verantwortlich?") + + Button(onClick = { + viewModel.setAnsprechperson("12345", "Ursula Stroblmair") + viewModel.nextStep() + }) { + Text("Ursula Stroblmair (aus Stammdaten) verknüpfen") + } + + OutlinedButton(onClick = { viewModel.nextStep() }) { + Text("Neue Person anlegen (Offline-Profil)") + } + } +} + +@Composable +private fun MetaDataStep(viewModel: EventWizardViewModel) { + val state = viewModel.state + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 4: Veranstaltungs-Parameter", style = MaterialTheme.typography.titleLarge) + + MsTextField( + value = state.name, + onValueChange = { viewModel.updateMetaData(it, state.ort, state.startDatum, state.endDatum, state.logoUrl) }, + label = "Name der Veranstaltung", + placeholder = "z.B. Oster-Turnier 2026", + modifier = Modifier.fillMaxWidth() + ) + + MsTextField( + value = state.ort, + onValueChange = { viewModel.updateMetaData(state.name, it, state.startDatum, state.endDatum, state.logoUrl) }, + label = "Veranstaltungs-Ort", + placeholder = "z.B. Reitanlage Musterstadt", + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Von", style = MaterialTheme.typography.labelMedium) + // Hier kommt ein DatePicker, wir simulieren das Datum + OutlinedButton( + onClick = { /* DatePicker Logik */ }, + modifier = Modifier.fillMaxWidth() + ) { + Text(state.startDatum?.toString() ?: "Datum wählen") + } + } + Column(modifier = Modifier.weight(1f)) { + Text("Bis (optional)", style = MaterialTheme.typography.labelMedium) + OutlinedButton( + onClick = { /* DatePicker Logik */ }, + modifier = Modifier.fillMaxWidth() + ) { + Text(state.endDatum?.toString() ?: "Datum wählen") + } + } + } + + MsFilePicker( + label = "Veranstaltungs-Logo (optional)", + selectedPath = state.logoUrl, + onFileSelected = { viewModel.updateMetaData(state.name, state.ort, state.startDatum, state.endDatum, it) }, + fileExtensions = listOf("png", "jpg", "jpeg", "svg"), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { viewModel.nextStep() }, + modifier = Modifier.align(Alignment.End), + enabled = state.name.isNotBlank() && state.ort.isNotBlank() && state.startDatum != null + ) { + Text("Weiter zur Turnier-Anlage") + } + } +} + +@Composable +private fun TurnierAnlageStep(viewModel: EventWizardViewModel) { + val state = viewModel.state + val turnierViewModel = viewModel.turnierWizardViewModel + var showWizard by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 5: Turniere & Ausschreibung", style = MaterialTheme.typography.titleLarge) + + if (showWizard) { + Card(modifier = Modifier.fillMaxWidth().height(600.dp)) { + TurnierWizard( + viewModel = turnierViewModel, + veranstaltungId = 0, // Mock-Modus + onBack = { showWizard = false }, + onFinish = { + showWizard = false + viewModel.addTurnier(turnierViewModel.state.turnierNr, "ZNS Ausschreibung") + } + ) + } + } else { + Text("Fügen Sie die pferdesportlichen Veranstaltungen (Turniere) hinzu.") + + state.turniere.forEachIndexed { index, turnier -> + ListItem( + headlineContent = { Text("Turnier #${index + 1}: ${turnier.nummer}") }, + trailingContent = { + IconButton(onClick = { viewModel.removeTurnier(index) }) { + Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) + } + } + ) + } + + OutlinedButton( + onClick = { showWizard = true }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Add, null) + Spacer(Modifier.width(8.dp)) + Text("Neues Turnier mit Wizard anlegen") + } + } + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { viewModel.nextStep() }, + modifier = Modifier.align(Alignment.End), + enabled = !showWizard && state.turniere.isNotEmpty() + ) { + Text("Weiter zur Zusammenfassung") + } + } +} + +@Composable +private fun SummaryStep(viewModel: EventWizardViewModel, onFinish: () -> Unit) { + val state = viewModel.state + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Schritt 6: Zusammenfassung", style = MaterialTheme.typography.titleLarge) + + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Überprüfen Sie Ihre Angaben, bevor Sie die Veranstaltung final anlegen.", + style = MaterialTheme.typography.bodyMedium + ) + + HorizontalDivider() + + SummaryItem("Veranstaltung", state.name) + SummaryItem("Veranstalter", "${state.veranstalterName} (${state.veranstalterVereinsNummer})") + SummaryItem("Ansprechperson", state.ansprechpersonName) + SummaryItem("Ort", state.ort) + SummaryItem("Zeitraum", "${state.startDatum} - ${state.endDatum ?: ""}") + + HorizontalDivider() + + Text("Turniere:", fontWeight = FontWeight.Bold) + state.turniere.forEach { turnier -> + Text("• Turnier-Nr: ${turnier.nummer}", style = MaterialTheme.typography.bodySmall) + } + } + } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = { + viewModel.saveVeranstaltung() + onFinish() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isSaving + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Veranstaltung jetzt anlegen") + } + } + } +} + +@Composable +private fun SummaryItem(label: String, value: String) { + Column { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + Text(value, style = MaterialTheme.typography.bodyMedium) + } +}