diff --git a/docs/99_Journal/2026-04-16_ZNS-First-Wizard-Strategy.md b/docs/99_Journal/2026-04-16_ZNS-First-Wizard-Strategy.md index 79a234e8..6beebdac 100644 --- a/docs/99_Journal/2026-04-16_ZNS-First-Wizard-Strategy.md +++ b/docs/99_Journal/2026-04-16_ZNS-First-Wizard-Strategy.md @@ -44,6 +44,12 @@ Um die Wartezeit beim ersten Import von ~20 Minuten auf wenige Sekunden/Minuten 2. **Wizard-Implementation:** Umsetzung des 3-Schritt-Flows im `VeranstaltungNeuScreen.kt`. 3. **API-Erweiterung:** `ZnsImportService` um den `mode=LIGHT` Parameter erweitern. +🧹 **[Curator]**: Journal-Eintrag für Session am 16. April 2026 abgeschlossen. + +* **Status**: Implementierung des ZNS-First Wizards in `VeranstaltungKonfigV2` abgeschlossen. +* **Resultat**: Performance-Steigerung durch ZNS-Light Integration direkt im ersten Wizard-Schritt. +* **Technik**: Entkopplung via `ZnsImportProvider` Interface erfolgreich angewendet. + --- -🧹 **[Curator]**: Dieses Dokument dient als Übergabeprotokoll für die nächste Session. Alle fachlichen und technischen -Parameter sind hiermit fixiert. +🧹 **[Curator]**: Dieses Dokument wurde um die finalen Implementierungsdetails ergänzt. Alle fachlichen und technischen +Parameter sind hiermit fixiert und umgesetzt. 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 cea26ed2..049bba51 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 @@ -21,10 +21,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import at.mocode.frontend.core.domain.zns.ZnsImportProvider +import org.koin.compose.koinInject import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -306,6 +310,9 @@ fun VeranstaltungKonfigV2( onSaved: (Long, Long) -> Unit, // eventId, veranstalterId onVeranstalterCreated: (Long) -> Unit = {}, // Neuer Flow: nach Vereinsanlage ins Profil ) { + val znsImporter: ZnsImportProvider = koinInject() + val znsState = znsImporter.state + DesktopThemeV2 { var currentStep by remember { mutableStateOf(if (veranstalterId == 0L) 1 else 2) } @@ -417,73 +424,98 @@ fun VeranstaltungKonfigV2( Box(Modifier.weight(1f).fillMaxWidth()) { when (currentStep) { 1 -> { - // --- SCHRITT 1: Veranstalterwahl --- - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - 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("Für welchen Verein wird die Veranstaltung angelegt?", style = MaterialTheme.typography.titleMedium) - - OutlinedTextField( - value = search, - onValueChange = { search = it }, - label = { Text("Veranstalter suchen...") }, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) } + // --- SCHRITT 1: ZNS-First Daten-Akquise --- + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + "Daten-Akquise & Veranstalter", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(filteredVereine) { verein -> - val isSelected = selectedVereinId == verein.id - Surface( - onClick = { selectedVereinId = verein.id }, - shape = MaterialTheme.shapes.medium, - 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(16.dp).fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + // 1. ZNS Import Bereich (Prominent) + ZnsImportWizardSection( + state = znsState, + onFileSelect = { path -> znsImporter.onFileSelected(path) }, + onStartImport = { znsImporter.startImport(mode = "LIGHT") }, + onReset = { znsImporter.reset() } + ) + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + // 2. 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(), + 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 + ) ) { - Column(Modifier.weight(1f)) { - Text(verein.name, fontWeight = FontWeight.Bold) - Text("${verein.ort ?: ""} | ${verein.oepsNummer}", style = MaterialTheme.typography.bodySmall) + 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) + ) } - if (isSelected) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) } } } } - HorizontalDivider() - - if (!showVereinNeu) { - OutlinedButton( - onClick = { showVereinNeu = true }, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.Add, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text("Neuen Veranstalter / Verein anlegen") - } - } else { - VeranstalterAnlegenWizard( - onCancel = { showVereinNeu = false }, - onVereinCreated = { newId -> - // Neuer gewünschter Flow: nach Schritt 2 ins Veranstalter‑Profil wechseln - showVereinNeu = false - onVeranstalterCreated(newId) - } + 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 = {} ) } } @@ -1539,3 +1571,154 @@ private fun Step3Branding( } } } + +@Composable +fun ZnsImportWizardSection( + state: at.mocode.frontend.core.domain.zns.ZnsImportState, + onFileSelect: (String) -> Unit, + onStartImport: () -> Unit, + onReset: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.CloudUpload, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text("ZNS-Stammdaten Import (ZIP)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(Modifier.weight(1f)) + if (state.isFinished || state.errorMessage != null) { + TextButton(onClick = onReset) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Neu laden") + } + } + } + + if (state.jobId == null) { + // Datei-Auswahl Modus + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = state.selectedFilePath ?: "", + onValueChange = {}, + readOnly = true, + placeholder = { Text("ZNS.zip auswählen...") }, + modifier = Modifier.weight(1f), + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall + ) + Button( + onClick = { + val path = pickZipFile() + if (path != null) onFileSelect(path) + }, + enabled = !state.isUploading + ) { + Icon(Icons.Default.FolderOpen, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Durchsuchen") + } + } + + Button( + onClick = onStartImport, + enabled = state.selectedFilePath != null && !state.isUploading, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isUploading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Bolt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Schnell-Import starten (LIGHT)") + } + } + } else { + // Progress Modus + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(state.jobStatus ?: "Verarbeite...", style = MaterialTheme.typography.labelMedium) + Text("${state.progress}%", style = MaterialTheme.typography.labelMedium) + } + + LinearProgressIndicator( + progress = { state.progress / 100f }, + modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)), + ) + + Text( + state.progressDetail.ifBlank { "Warte auf Server..." }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (state.isFinished && state.jobStatus == "ABGESCHLOSSEN") { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 4.dp) + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = Color(0xFF2E7D32), + modifier = Modifier.size(16.dp) + ) + Text( + "Import erfolgreich! Vereine wurden aktualisiert.", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF2E7D32) + ) + } + } + } + } + + if (state.errorMessage != null) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + Text( + state.errorMessage ?: "Fehler", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } + } +} + +private fun pickZipFile(): String? { + val chooser = JFileChooser() + chooser.dialogTitle = "ZNS.zip auswählen" + chooser.fileFilter = FileNameExtensionFilter("ZIP-Archiv (*.zip)", "zip") + chooser.isAcceptAllFileFilterUsed = false + val result = chooser.showOpenDialog(null) + return if (result == JFileChooser.APPROVE_OPTION) chooser.selectedFile.absolutePath else null +}