feat(veranstaltung): UI-Refactoring und Validierung für Veranstaltungsverwaltung hinzugefügt

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-16 12:37:44 +02:00
parent 82a4a13505
commit eb0fac5989
5 changed files with 322 additions and 82 deletions
@@ -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`
@@ -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.")
}
}
}
}
}
@@ -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
)
}
}
}
}
@@ -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)
@@ -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))