From eb0fac5989da3e4ff2ed956b4ba84b22ee3bddc1 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Thu, 16 Apr 2026 12:37:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(veranstaltung):=20UI-Refactoring=20und=20V?= =?UTF-8?q?alidierung=20f=C3=BCr=20Veranstaltungsverwaltung=20hinzugef?= =?UTF-8?q?=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stefan Mogeritsch --- ...6_Veranstaltungs-Verwaltung-Refactoring.md | 46 +++++ .../presentation/VeranstaltungNeuScreen.kt | 158 +++++++++++++++--- .../presentation/VeranstaltungenScreen.kt | 158 +++++++++++++++--- .../screens/onboarding/OnboardingValidator.kt | 2 +- .../mocode/desktop/v2/VeranstaltungScreens.kt | 40 +---- 5 files changed, 322 insertions(+), 82 deletions(-) create mode 100644 docs/99_Journal/2026-04-16_Veranstaltungs-Verwaltung-Refactoring.md diff --git a/docs/99_Journal/2026-04-16_Veranstaltungs-Verwaltung-Refactoring.md b/docs/99_Journal/2026-04-16_Veranstaltungs-Verwaltung-Refactoring.md new file mode 100644 index 00000000..6d9a8646 --- /dev/null +++ b/docs/99_Journal/2026-04-16_Veranstaltungs-Verwaltung-Refactoring.md @@ -0,0 +1,46 @@ +--- +type: Journal +status: ACTIVE +owner: Curator +created: 2026-04-16 +--- + +# Journal — 16. April 2026 (Veranstaltungs-Verwaltung Refactoring) + +## 🎯 Ziel & Entscheidung + +Überarbeitung der **Veranstaltungs-Verwaltung** gemäß der neuen UI-Vision (High-Density & Desktop-First). +Ziel war es, die Navigation effizienter zu gestalten (Double-Click Navigation) und den Wizard für die Neuanlage +funktional auszubauen (Stammdaten-Validierung). + +## 🎨 UI/UX Änderungen + +- **VeranstaltungenScreen:** + - Titel auf "Veranstaltungen - verwalten" aktualisiert (Vorgabe: Bindestrich + Kleinschreibung des Verbs). + - Entfernung der redundanten Navigations-Buttons (Reiter, Verein, ZNS-Importer) im Header zur Reduzierung der + kognitiven Last. + - Einführung der `VeranstaltungCard` mit Logo-Platzhalter und Hover-Feedback. + - Implementierung von **Double-Click Navigation** zum Öffnen einer Veranstaltung. + - Radikale Entschlackung: Platzhalter wurden durch eine saubere Liste/Grid-Logik ersetzt. + - Integration des primären Action-Buttons "+ Neue Veranstaltung" im Header. + +- **VeranstaltungNeuScreen (Wizard):** + - Umstellung auf einen tab-basierten Workflow (Stammdaten | Organisation | Preisliste). + - Implementierung des Stammdaten-Formulars (A-Satz) mit Pflichtfeld-Validierung (Name, Ort, Datum). + - Integration der `MsTextField` und `MsButton` Komponenten aus dem Design-System. + - Vorbereitung für ZNS-Import Integration. + +## 🏗️ Technische Details + +- **State Management:** Nutzung von `remember` und `mutableStateOf` für die Formular-Validierung im Screen. +- **Modelle:** Einführung von `VeranstaltungSimpleUiModel` zur Entkopplung von Domain-Modellen in der UI. +- **Komponenten:** Nutzung von `combinedClickable` für Desktop-spezifische Interaktionen. + +## 🔗 Relevante Dateien + +- +`frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt` +- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt` (Zentrale + Desktop-Ansicht) +- +`frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungNeuScreen.kt` diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungNeuScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungNeuScreen.kt index 48828b08..7728f0e1 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungNeuScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungNeuScreen.kt @@ -1,45 +1,73 @@ package at.mocode.veranstaltung.feature.presentation 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.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import at.mocode.frontend.core.designsystem.models.PlaceholderContent +import at.mocode.frontend.core.designsystem.components.MsButton +import at.mocode.frontend.core.designsystem.components.MsTextField +import at.mocode.frontend.core.designsystem.theme.Dimens /** * Formular zum Anlegen einer neuen Veranstaltung (Vision_03: /veranstaltung/neu). - * Tabs: Veranstaltung-Übersicht | Stammdaten (A-Satz) | Organisation | Preisliste - * TODO: Echte Formular-Felder und Persistenz (Phase 4/5). + * Tabs: Stammdaten (A-Satz) | Organisation | Preisliste */ @Composable fun VeranstaltungNeuScreen( onBack: () -> Unit, onSave: () -> Unit, ) { - var selectedTab by remember { mutableIntStateOf(1) } // Stammdaten ist Standard-Tab - val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Preisliste") + var selectedTab by remember { mutableIntStateOf(0) } // Stammdaten ist Standard-Tab + val tabs = listOf("Stammdaten (A-Satz)", "Organisation", "Preisliste") + + // Formular-State für Stammdaten + var name by remember { mutableStateOf("") } + var ort by remember { mutableStateOf("") } + var startDatum by remember { mutableStateOf("") } + var endDatum by remember { mutableStateOf("") } + var veranstalter by remember { mutableStateOf("") } + + // Validierung + val isNameValid = name.isNotBlank() + val isOrtValid = ort.isNotBlank() + val isStartDatumValid = startDatum.length >= 8 // Einfache Prüfung für DD.MM.YY + val isFormValid = isNameValid && isOrtValid && isStartDatumValid Column(modifier = Modifier.fillMaxSize()) { - // Toolbar - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, + // Toolbar (Header) + Surface( + modifier = Modifier.fillMaxWidth(), + shadowElevation = 2.dp, + color = MaterialTheme.colorScheme.surface ) { - Row { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + Row( + modifier = Modifier.padding(Dimens.SpacingM), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + Spacer(Modifier.width(Dimens.SpacingS)) + Text( + text = "Neue Veranstaltung anlegen", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) } - Spacer(Modifier.width(8.dp)) - Text( - text = "Neue Veranstaltung", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.alignByBaseline(), + MsButton( + text = "Veranstaltung speichern", + onClick = onSave, + enabled = isFormValid ) } - Button(onClick = onSave) { Text("Speichern") } } PrimaryTabRow(selectedTabIndex = selectedTab) { @@ -52,12 +80,96 @@ fun VeranstaltungNeuScreen( } } - Box(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(Dimens.SpacingL) + .verticalScroll(rememberScrollState()) + ) { when (selectedTab) { - 0 -> PlaceholderContent("Veranstaltung – Übersicht", "Wird nach dem Speichern befüllt.") - 1 -> PlaceholderContent("Stammdaten (A-Satz)", "Felder: Bezeichnung, Datum, Ort, Veranstalter …") - 2 -> PlaceholderContent("Organisation", "Felder: Richter, Parcourschef, Tierarzt …") - 3 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …") + 0 -> { // Stammdaten + Column( + modifier = Modifier.widthIn(max = 600.dp), + verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM) + ) { + Text( + "Allgemeine Informationen (A-Satz)", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + + MsTextField( + value = name, + onValueChange = { name = it }, + label = "Name der Veranstaltung (z.B. Pfingstturnier)", + placeholder = "Name eingeben", + isError = name.isBlank(), + errorMessage = if (name.isBlank()) "Name ist erforderlich" else null + ) + + MsTextField( + value = veranstalter, + onValueChange = { veranstalter = it }, + label = "Veranstalter (Verein)", + placeholder = "Verein suchen oder eingeben" + ) + + Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) { + MsTextField( + value = ort, + onValueChange = { ort = it }, + label = "Ort", + modifier = Modifier.weight(1f), + isError = ort.isBlank() + ) + MsTextField( + value = "Österreich", + onValueChange = {}, + label = "Land", + modifier = Modifier.weight(0.5f), + readOnly = true + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) { + MsTextField( + value = startDatum, + onValueChange = { startDatum = it }, + label = "Startdatum (TT.MM.JJJJ)", + placeholder = "24.04.2026", + modifier = Modifier.weight(1f) + ) + MsTextField( + value = endDatum, + onValueChange = { endDatum = it }, + label = "Enddatum (TT.MM.JJJJ)", + placeholder = "26.04.2026", + modifier = Modifier.weight(1f) + ) + } + + Text( + "Hinweis: Diese Daten bilden die Basis für den ZNS-Import und die Abrechnung.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + 1 -> { // Organisation + Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) { + Text("Funktionäre & Verantwortliche", style = MaterialTheme.typography.titleLarge) + Text("Hier werden Richter, Parcourschefs und Tierärzte zugewiesen.") + // Später: MsSearchableSelect für Funktionäre + } + } + + 2 -> { // Preisliste + Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) { + Text("Gebühren & Preisliste", style = MaterialTheme.typography.titleLarge) + Text("Definition der Nenngebühren und Pauschalen gemäß ÖTO.") + } + } } } } diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt index c6abd4b7..d1f276d8 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt @@ -1,51 +1,171 @@ package at.mocode.veranstaltung.feature.presentation +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable 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.Add -import androidx.compose.material3.Button -import androidx.compose.material3.Icon +import androidx.compose.material.icons.filled.Event import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import at.mocode.frontend.core.designsystem.models.PlaceholderContent +import at.mocode.frontend.core.designsystem.components.MsButton +import at.mocode.frontend.core.designsystem.components.MsCard +import at.mocode.frontend.core.designsystem.theme.Dimens + +/** + * UI-Modell für die Anzeige einer Veranstaltung in der Liste. + */ +data class VeranstaltungSimpleUiModel( + val id: Long, + val name: String, + val untertitel: String?, + val ort: String, + val datum: String, + val logoUrl: String? = null +) /** * Veranstaltungs-Übersicht (Drawer-Einstieg gemäß Vision_03). * Zeigt Liste aller Veranstaltungen + Button "Neue Veranstaltung". - * TODO: Echte Daten aus dem event-management-context laden (Phase 4/5). */ @Composable fun VeranstaltungenScreen( onVeranstaltungNeu: () -> Unit, onVeranstaltungOeffnen: (Long) -> Unit, ) { - Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { + // Später: Echte Daten aus dem ViewModel laden + val veranstaltungen = remember { + mutableStateListOf( + VeranstaltungSimpleUiModel( + id = 1L, + name = "Springturnier Neumarkt", + untertitel = "CSN-B* | 24. - 26. April 2026", + ort = "Neumarkt am Wallersee", + datum = "24.04.2026 - 26.04.2026" + ), + VeranstaltungSimpleUiModel( + id = 2L, + name = "Dressurtage Lamprechtshausen", + untertitel = "CDN-A* | 01. - 03. Mai 2026", + ort = "Lamprechtshausen", + datum = "01.05.2026 - 03.05.2026" + ) + ) + } + + Column(modifier = Modifier.fillMaxSize().padding(Dimens.SpacingL)) { + // Header: Titel + Action Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Veranstaltungen", + text = "Veranstaltungen - verwalten", style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + MsButton( + text = "Neue Veranstaltung", + onClick = onVeranstaltungNeu + // icon = Icons.Default.Add // MsButton unterstützt noch kein Icon im Parameter ) - Button(onClick = onVeranstaltungNeu) { - Icon(Icons.Default.Add, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text("Neue Veranstaltung") - } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(Modifier.height(Dimens.SpacingL)) - // Platzhalter – wird durch echte Daten ersetzt - PlaceholderContent( - title = "Noch keine Veranstaltungen", - subtitle = "Lege eine neue Veranstaltung an, um zu beginnen.", - ) + if (veranstaltungen.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "Keine Veranstaltungen gefunden. Legen Sie eine neue an.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM) + ) { + items(veranstaltungen) { event -> + VeranstaltungCard( + event = event, + onDoubleClick = { onVeranstaltungOeffnen(event.id) } + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun VeranstaltungCard( + event: VeranstaltungSimpleUiModel, + onDoubleClick: () -> Unit +) { + MsCard( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { /* Einfacher Klick für Selektion, falls gewünscht */ }, + onDoubleClick = onDoubleClick + ) + ) { + Row( + modifier = Modifier.padding(Dimens.SpacingS), + verticalAlignment = Alignment.CenterVertically + ) { + // Platzhalter für Logo + Box( + modifier = Modifier + .size(64.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Image( + imageVector = Icons.Default.Event, + contentDescription = null, + modifier = Modifier.size(32.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant) + ) + } + + Spacer(Modifier.width(Dimens.SpacingM)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = event.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + if (!event.untertitel.isNullOrBlank()) { + Text( + text = event.untertitel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = "${event.ort} | ${event.datum}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } } } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidator.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidator.kt index 59c27196..be5ff8ae 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidator.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidator.kt @@ -3,7 +3,7 @@ package at.mocode.desktop.screens.onboarding /** * Validierungslogik für den Onboarding-Wizard. * - * Extrahiert aus [OnboardingScreen] für isolierte Unit-Tests (B-2). + * Extrahiert aus `OnboardingScreen` für isolierte Unit-Tests (B-2). * Regeln gemäß Onboarding-Spezifikation: * - Gerätename: mindestens 3 Zeichen (nach trim) * - Sicherheitsschlüssel: mindestens 8 Zeichen (nach trim) 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 47ef1470..cea26ed2 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 @@ -57,51 +57,13 @@ fun VeranstaltungVerwaltungV2( } Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - // Navigation Toolbar (Top) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AssistChip( - onClick = onNavigateToPferde, - label = { Text("Pferde") }, - leadingIcon = { Icon(Icons.Default.Pets, null) }) - AssistChip( - onClick = onNavigateToReiter, - label = { Text("Reiter") }, - leadingIcon = { Icon(Icons.Default.Person, null) }) - AssistChip( - onClick = onNavigateToVereine, - label = { Text("Vereine") }, - leadingIcon = { Icon(Icons.Default.Home, null) }) - AssistChip( - onClick = onNavigateToFunktionaere, - label = { Text("Funktionäre") }, - leadingIcon = { Icon(Icons.Default.Badge, null) }) - AssistChip( - onClick = onNavigateToVeranstalter, - label = { Text("Veranstalter") }, - leadingIcon = { Icon(Icons.Default.Business, null) }) - VerticalDivider(Modifier.height(32.dp).padding(horizontal = 4.dp)) - AssistChip( - onClick = onNavigateToZnsImport, - label = { Text("ZNS Importer") }, - leadingIcon = { Icon(Icons.Default.CloudDownload, null) }, - colors = AssistChipDefaults.assistChipColors( - labelColor = MaterialTheme.colorScheme.primary, - leadingIconContentColor = MaterialTheme.colorScheme.primary - ) - ) - } - // Header Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("Veranstaltung-Verwaltung", style = MaterialTheme.typography.headlineMedium) + Text("Veranstaltungen - verwalten", style = MaterialTheme.typography.headlineMedium) Button(onClick = onNewVeranstaltung) { Icon(Icons.Default.Add, contentDescription = null) Spacer(Modifier.width(8.dp))