feat(event-feature): enhance Veranstaltungs- & Turnier-Workflow
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Waiting to run

- Extended `Veranstaltung` domain model with new fields: `untertitel`, `logoUrl`, and `sponsoren`.
- Refined navigation in `DesktopMainLayout.kt` to check turnier context and improve routing.
- Overhauled `TurnierStammdatenTab` with enhanced interactivity: dynamic chip-based selectors for Spartens, Klassen, and Sponsors, as well as date pickers and ZNS import handling.
- Implemented validations for date ranges and required fields.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-31 17:33:07 +02:00
parent 496e801943
commit f44b2c8126
13 changed files with 1199 additions and 257 deletions
@@ -1,6 +1,8 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -34,6 +36,11 @@ fun TurnierDetailScreen(
) {
var selectedTab by remember { mutableIntStateOf(0) }
// Temporäre Lösung bis zur echten Repository-Anbindung:
// Da TurnierDetailScreen in einem anderen Modul liegt, übergeben wir
// die Veranstaltungsinformationen eigentlich via ViewModel.
// Hier nutzen wir vorerst koin oder Parameter.
val tabs = listOf(
"STAMMDATEN",
"ORGANISATION",
@@ -1,11 +1,11 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.clickable
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.filled.CloudDownload
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -14,6 +14,7 @@ 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 java.time.LocalDate
private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6)
@@ -26,214 +27,328 @@ private val AccentBlue = Color(0xFF3B82F6)
* - Turnier-Beschreibung: Titel, Sub-Titel
* - Sponsoren
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StammdatenTabContent(turnierId: Long) {
// In einer echten App würden wir diese Daten aus einem ViewModel laden.
// Hier simulieren wir den State basierend auf den Anforderungen.
var turnierNr by remember { mutableStateOf("") }
var typOto by remember { mutableStateOf(true) }
var spracheDe by remember { mutableStateOf(true) }
var sparteDressur by remember { mutableStateOf(false) }
var sparteSpringen by remember { mutableStateOf(false) }
var klasseC by remember { mutableStateOf(false) }
var klasseB by remember { mutableStateOf(false) }
var klasseA by remember { mutableStateOf(false) }
var datumVon by remember { mutableStateOf("") }
var datumBis by remember { mutableStateOf("") }
var nrConfirmed by remember { mutableStateOf(false) }
var znsDataLoaded by remember { mutableStateOf(false) }
var typ by remember { mutableStateOf("ÖTO (National)") }
val sparten = remember { mutableStateListOf<String>() }
val klassen = remember { mutableStateListOf<String>() }
val kat = remember { mutableStateListOf<String>() }
var von by remember { mutableStateOf("") }
var bis by remember { mutableStateOf("") }
var ort by remember { mutableStateOf("") }
var titel by remember { mutableStateOf("") }
var subTitel by remember { mutableStateOf("") }
val sponsoren = remember { mutableStateListOf<String>() }
var showZnsDialog by remember { mutableStateOf(false) }
// Hilfs-States für DatePicker
var showDatePickerVon by remember { mutableStateOf(false) }
var showDatePickerBis by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
// ── Turnier-Konfiguration ────────────────────────────────────────────
SectionCard(title = "Turnier-Konfiguration") {
// ── Turnier-Konfiguration (Schritt 1 Logik) ───────────────────────────
SectionCard(title = "Turnier-Konfiguration & ZNS") {
FormRow("Turnier-Nr.:") {
OutlinedTextField(
value = turnierNr,
onValueChange = { turnierNr = it },
placeholder = { Text("z.B. 26128", fontSize = 13.sp) },
modifier = Modifier.width(200.dp).height(48.dp),
singleLine = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = turnierNr,
onValueChange = { if (it.length <= 5 && it.all { c -> c.isDigit() }) turnierNr = it },
placeholder = { Text("5-stellig", fontSize = 13.sp) },
modifier = Modifier.width(120.dp),
singleLine = true,
enabled = !nrConfirmed
)
if (!nrConfirmed) {
Button(
onClick = { nrConfirmed = true },
enabled = turnierNr.length == 5,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
) {
Text("Bestätigen")
}
} else {
InputChip(
selected = true,
onClick = { nrConfirmed = false },
label = { Text("Bestätigt") },
trailingIcon = { Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(16.dp)) }
)
}
}
if (turnierNr.length == 5 && !nrConfirmed) {
Text(
"Bitte Turnier-Nummer bestätigen um fortzufahren.",
color = MaterialTheme.colorScheme.error,
fontSize = 11.sp
)
}
}
FormRow("Typ:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = typOto, onClick = { typOto = true })
Text("OTO (National)", fontSize = 13.sp)
RadioButton(selected = !typOto, onClick = { typOto = false })
Text("FEI (International)", fontSize = 13.sp)
FilterChip(
selected = typ == "ÖTO (National)",
onClick = { typ = "ÖTO (National)" },
label = { Text("ÖTO (National)") }
)
FilterChip(
selected = typ == "FEI (International)",
onClick = { typ = "FEI (International)" },
label = { Text("FEI (International)") }
)
}
}
FormRow("ZNS-Daten:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FormRow("ZNS-Stammdaten:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue),
onClick = { showZnsDialog = true },
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue)
) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Import via Internet", fontSize = 13.sp)
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import via Internet")
}
OutlinedButton(onClick = {}) {
Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Import via USB", fontSize = 13.sp)
OutlinedButton(onClick = { showZnsDialog = true }) {
Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import via USB")
}
}
Text(
"Reiter-, Pferde-, Funktionärs- und Vereinsdaten vom OEPS Backend",
fontSize = 11.sp,
color = Color(0xFF6B7280),
)
}
FormRow("Sprache:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = spracheDe, onClick = { spracheDe = true })
Text("Deutsch", fontSize = 13.sp)
RadioButton(selected = !spracheDe, onClick = { spracheDe = false })
Text("English", fontSize = 13.sp)
val znsStatusColor = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = znsStatusColor,
modifier = Modifier.size(16.dp)
)
Text(
if (znsDataLoaded) "ZNS-Daten geladen" else "Keine ZNS-Daten geladen",
color = znsStatusColor,
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
FormRow("Sparten:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = sparteDressur, onCheckedChange = { sparteDressur = it })
Text("Dressur", fontSize = 13.sp)
Checkbox(checked = sparteSpringen, onCheckedChange = { sparteSpringen = it })
Text("Springen", fontSize = 13.sp)
}
// ── Sparten & Kategorien (Schritt 2 Logik) ───────────────────────────
SectionCard(title = "Reglement & Sparten") {
FormRow("Sparte:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = sparten.contains("Dressur"),
onClick = { if (sparten.contains("Dressur")) sparten.remove("Dressur") else sparten.add("Dressur") },
label = { Text("Dressur") }
)
FilterChip(
selected = sparten.contains("Springen"),
onClick = { if (sparten.contains("Springen")) sparten.remove("Springen") else sparten.add("Springen") },
label = { Text("Springen") }
)
}
}
FormRow("Klassen:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = klasseC, onCheckedChange = { klasseC = it })
Text("C", fontSize = 13.sp)
Checkbox(checked = klasseB, onCheckedChange = { klasseB = it })
Text("B", fontSize = 13.sp)
Checkbox(checked = klasseA, onCheckedChange = { klasseA = it })
Text("A", fontSize = 13.sp)
}
}
FormRow("Kategorien:") {
Surface(
modifier = Modifier.fillMaxWidth().height(60.dp),
color = Color(0xFFF3F4F6),
shape = MaterialTheme.shapes.small,
) {
Box(contentAlignment = Alignment.Center) {
Text(
"Bitte Sparte(n) auswählen",
fontSize = 13.sp,
color = Color(0xFF9CA3AF),
FormRow("Klasse:") {
val klassenListe = listOf("C-NEU", "C", "B", "A", "L", "LM", "M", "S")
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
klassenListe.forEach { k ->
FilterChip(
selected = klassen.contains(k),
onClick = { if (klassen.contains(k)) klassen.remove(k) else klassen.add(k) },
label = { Text(k) }
)
}
}
}
FormRow("Datum:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
FormRow("Kategorien:") {
// Logik zur Generierung der Kategorien
val suggested = mutableListOf<String>()
sparten.forEach { s ->
val prefix = if (s == "Dressur") "CDN" else "CSN"
klassen.forEach { k ->
suggested.add("$prefix-$k")
suggested.add("${prefix}P-$k") // Pony Variante
}
}
if (suggested.isEmpty()) {
Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp)
} else {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
suggested.forEach { c ->
InputChip(
selected = kat.contains(c),
onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) },
label = { Text(c) }
)
}
}
}
}
FormRow("Zeitraum:") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = datumVon,
onValueChange = { datumVon = it },
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) },
modifier = Modifier.width(160.dp).height(48.dp),
singleLine = true,
value = von,
onValueChange = {},
label = { Text("Von") },
modifier = Modifier.width(160.dp).clickable { showDatePickerVon = true },
readOnly = true,
trailingIcon = { Icon(Icons.Default.DateRange, null) }
)
Text("bis", fontSize = 13.sp)
Text("bis")
OutlinedTextField(
value = datumBis,
onValueChange = { datumBis = it },
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) },
modifier = Modifier.width(160.dp).height(48.dp),
singleLine = true,
value = bis,
onValueChange = {},
label = { Text("Bis") },
modifier = Modifier.width(160.dp).clickable { showDatePickerBis = true },
readOnly = true,
trailingIcon = { Icon(Icons.Default.DateRange, null) }
)
}
Text("Hinweis: Muss innerhalb des Veranstaltungs-Zeitraums liegen.", fontSize = 11.sp, color = Color.Gray)
}
FormRow("Ort:") {
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Austragungsort") },
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Muss mit Veranstaltungsort übereinstimmen.") }
)
}
}
// ── Turnier-Beschreibung ─────────────────────────────────────────────
SectionCard(title = "Turnier-Beschreibung") {
// ── Branding (Schritt 3 Logik) ───────────────────────────────────────
SectionCard(title = "Turnier-Branding") {
OutlinedTextField(
value = titel,
onValueChange = { titel = it },
placeholder = { Text("z.B. Frühjahrs-Turnier 2026", fontSize = 13.sp) },
label = { Text("Titel") },
modifier = Modifier.fillMaxWidth().height(56.dp),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = subTitel,
onValueChange = { subTitel = it },
placeholder = { Text("z.B. KIDS CUP • PONY EINSTEIGER CUP OÖ", fontSize = 13.sp) },
label = { Text("Sub-Titel") },
modifier = Modifier.fillMaxWidth().height(56.dp),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
// ── Sponsoren ────────────────────────────────────────────────────────
SectionCard(
title = "Sponsoren",
action = {
TextButton(onClick = {}) {
Text("+ Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp)
}
},
) {
Surface(
modifier = Modifier.fillMaxWidth().height(80.dp),
color = Color(0xFFF9FAFB),
shape = MaterialTheme.shapes.small,
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Noch keine Sponsoren hinzugefügt", fontSize = 13.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(4.dp))
TextButton(onClick = {}) {
Text("+ Ersten Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp)
}
FormRow("Sponsoren:") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
sponsoren.forEach { s ->
InputChip(
selected = true,
onClick = { sponsoren.remove(s) },
label = { Text(s) },
trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(14.dp)) }
)
}
TextButton(onClick = { sponsoren.add("Neuer Sponsor") }) {
Text("+ Hinzufügen")
}
}
}
}
// ── Aktions-Buttons ──────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = {}) { Text("Zurücksetzen") }
Spacer(Modifier.width(8.dp))
// ── Footer ──────────────────────────────────────────────────────────
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = {},
onClick = { /* Speichern */ },
enabled = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank() && titel.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Speichern") }
modifier = Modifier.padding(bottom = 24.dp)
) {
Icon(Icons.Default.Save, null)
Spacer(Modifier.width(8.dp))
Text("Änderungen speichern")
}
}
}
// Dialog-Simulationen
if (showZnsDialog) {
AlertDialog(
onDismissRequest = { showZnsDialog = false },
title = { Text("ZNS Import") },
text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") },
confirmButton = {
TextButton(onClick = { znsDataLoaded = true; showZnsDialog = false }) { Text("Importieren") }
},
dismissButton = {
TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") }
}
)
}
if (showDatePickerVon) {
val state = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = { showDatePickerVon = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let {
von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
}
showDatePickerVon = false
}) { Text("OK") }
}
) { DatePicker(state) }
}
if (showDatePickerBis) {
val state = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = { showDatePickerBis = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let {
bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
}
showDatePickerBis = false
}) { Text("OK") }
}
) { DatePicker(state) }
}
}
@Composable
private fun SectionCard(
title: String,
action: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
action?.invoke()
}
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = PrimaryBlue, fontWeight = FontWeight.Bold)
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
content()
}
}
@@ -241,20 +356,14 @@ private fun SectionCard(
@Composable
private fun FormRow(label: String, content: @Composable ColumnScope.() -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
Row(Modifier.fillMaxWidth()) {
Text(
text = label,
fontSize = 13.sp,
label,
modifier = Modifier.width(140.dp).padding(top = 12.dp),
color = Color(0xFF374151),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
content()
}
}