### feat: optimiere Online-Nennformular und Turnier-Integration
Build and Publish Docker Images / build-and-push (., backend/services/mail/Dockerfile, mail-service, mail-service) (push) Successful in 6m27s
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Successful in 4m18s

- **`OnlineNennungFormular.kt`:**
  - Erweiterung um Felder für Telefon und Pferdename.
  - Dynamische Validierung und UI-Anpassungen für mobile Geräte.
  - Zusätzliche Bewerbslisten und Auswahlbeschränkungen hinzugefügt.
- **`WebMainScreen.kt`:**
  - Aktualisierte Turniermetadata und verbesserte Responsivität.
This commit is contained in:
2026-04-23 04:48:46 +02:00
parent f620f46d15
commit 568d9dbb32
3 changed files with 494 additions and 130 deletions
@@ -0,0 +1,31 @@
# Journal-Eintrag: Plan-B Online-Nenn-Formulare
**Datum:** 23. April 2026
**Agenten:** 🎨 [Frontend Expert], 🖌️ [UI/UX Designer], 👷 [Backend Developer], 🧹 [Curator]
## 🎯 Zielsetzung
Erstellung von zwei hoch-optimierten Web-Formularen für die Turniere in Neumarkt (25. & 26. April 2026) im Rahmen des "Plan-B" (Offline-Meldestelle mit E-Mail-Sync).
## 🛠️ Durchgeführte Änderungen
### 🎨 Frontend & UI/UX
- **`OnlineNennungFormular.kt`**: Komplette Neugestaltung des Formulars.
- Integration der spezifischen Bewerbe für **CSN-C Neumarkt (25.04.)** und **CDN-C Neumarkt (26.04.)**.
- Implementierung der Validierungslogik für den "Jetzt nennen" Button (Bernstein-Orange).
- Hinzufügen von Feldern für Reiter-Name, Kontakt (E-Mail/Tel), Pferdename und Anmerkungen.
- Information Density: Alle Bewerbe direkt auswählbar.
- **Mobile-First Optimierung**: Responsives Layout mittels `BoxWithConstraints`. Vertikaler Stack für Formularfelder auf Mobile, optimierte Paddings, Schriftgrößen und Touch-Targets.
- **`WebMainScreen.kt`**: Aktualisierung der Landing-Page mit den realen Turnierdaten für Neumarkt.
- **Mobile-First Optimierung**: Turnier-Karten passen sich an schmale Bildschirme an (Buttons nebeneinander, Icons für bessere UX).
### 👷 Backend & Integration
- **`NennungRemoteRepository.kt`**: Verknüpfung des neuen Payloads mit dem `mail-service`.
- **`MailController.kt`**: Validierung der API-Schnittstelle. Der Service ist so konfiguriert, dass er:
1. Die Nennung in der Datenbank persistiert.
2. Eine Benachrichtigungs-Mail an die Meldestelle (`online-nennen@mo-code.at`) sendet.
3. Eine automatische Bestätigung an den Reiter schickt.
## 🏁 Ergebnis
Die "Hallo Du!" Test-UI wurde durch produktive, fachlich korrekte Formulare ersetzt. Sobald ein Reiter auf "Jetzt nennen" klickt, wird der E-Mail-Workflow ausgelöst.
**Status:** Bereit für den Live-Einsatz am Wochenende. 🚀
@@ -1,14 +1,24 @@
package at.mocode.frontend.features.nennung.presentation.web
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.AppColors
@@ -35,93 +45,383 @@ fun OnlineNennungFormular(
) {
var vorname by remember { mutableStateOf("") }
var nachname by remember { mutableStateOf("") }
var telefon by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var pferdName by remember { mutableStateOf("") }
var bemerkungen by remember { mutableStateOf("") }
val ausgewaehlteBewerbe = remember { mutableStateListOf<Bewerb>() }
val focusManager = LocalFocusManager.current
val bewerbeListe = remember(turnierNr) {
if (turnierNr == "26128") {
listOf(
Bewerb(1, "Sa", 1, "", "Pony Stilspringprüfung (60 cm)", Sparte.SPRINGEN, "Pony"),
Bewerb(
2,
"Sa",
1,
"",
"Einlaufspringprüfung (60 cm) - Abt. 1: liz.frei / Abt. 2: mit Lizenz",
Sparte.SPRINGEN,
"E"
),
Bewerb(3, "Sa", 1, "", "Pony Stilspringprüfung (70 cm)", Sparte.SPRINGEN, "Pony"),
Bewerb(
4,
"Sa",
1,
"",
"Einlaufspringprüfung (70 cm) - Abt. 1: liz.frei / Abt. 2: mit Lizenz",
Sparte.SPRINGEN,
"E"
),
Bewerb(5, "Sa", 1, "", "Pony Stilspringprüfung (80 cm)", Sparte.SPRINGEN, "Pony"),
Bewerb(
6,
"Sa",
1,
"",
"Stilspringprüfung (80 cm) - Abt. 1: liz.frei / Abt. 2: R1 & 5-6j. Pf.",
Sparte.SPRINGEN,
"E"
),
Bewerb(7, "Sa", 1, "", "Pony Stilspringprüfung (95 cm)", Sparte.SPRINGEN, "Pony"),
Bewerb(8, "Sa", 1, "", "Springreiterbewerb liz.frei (95 cm)", Sparte.SPRINGEN, "E"),
Bewerb(9, "Sa", 1, "", "Standardspringprüfung (95 cm) - Abt. 1: R1 / Abt. 2: R2+", Sparte.SPRINGEN, "A1"),
Bewerb(
10,
"Sa",
1,
"",
"Springpferdeprüfung (105 cm) - Abt. 1: 4j. / Abt. 2: 5-6j.",
Sparte.SPRINGEN,
"A"
),
Bewerb(11, "Sa", 1, "", "Stilspringprüfung (105 cm) - Abt. 1: R1", Sparte.SPRINGEN, "A2"),
Bewerb(
12,
"Sa",
1,
"",
"Standardspringprüfung (105 cm) - Abt. 1: R1 / Abt. 2: R2/RS2+",
Sparte.SPRINGEN,
"A2"
),
Bewerb(13, "Sa", 1, "", "Stilspringprüfung (115 cm) - Abt. 1: R1", Sparte.SPRINGEN, "L"),
Bewerb(
14,
"Sa",
1,
"",
"Standardspringprüfung (115 cm) - Abt. 1: R1 / Abt. 2: R2/RS2+",
Sparte.SPRINGEN,
"L"
),
)
} else {
listOf(
Bewerb(1, "So", 1, "", "Dressurreiterprüfung Reiterpass (Aufg. R1)", Sparte.DRESSUR, "RP"),
Bewerb(2, "So", 1, "", "Dressurreiterprüfung Reiternadel (Aufg. R4)", Sparte.DRESSUR, "RN"),
Bewerb(3, "So", 1, "", "Dressurreiterprüfung lizenzfrei (Aufg. LF1)", Sparte.DRESSUR, "LF"),
Bewerb(4, "So", 1, "", "Dressurreiterprüfung lizenzfrei (Aufg. LF3)", Sparte.DRESSUR, "LF"),
Bewerb(5, "So", 1, "", "First Ridden", Sparte.DRESSUR, "FR"),
Bewerb(6, "So", 1, "", "Führzügelklasse", Sparte.DRESSUR, "FZ"),
Bewerb(7, "So", 1, "", "Pony Dressurprüfung Kl. A (Aufg. P1)", Sparte.DRESSUR, "A"),
Bewerb(
8,
"So",
1,
"",
"Dressurreiterprüfung Kl. A (Aufg. DRA1) - Abt. 1: R1/RD1 / Abt. 2: R2/RD2+",
Sparte.DRESSUR,
"A"
),
Bewerb(
9,
"So",
1,
"",
"Dressurprüfung Kl. A (Aufg. A5) - Abt. 1: R1/RD1 / Abt. 2: R2/RD2+",
Sparte.DRESSUR,
"A"
),
Bewerb(
13,
"So",
1,
"",
"Dressurpferdeprüfung Kl. A (Aufg. DPA1) - Abt. 1: 4j. / Abt. 2: 5-6j.",
Sparte.DRESSUR,
"DP-A"
),
Bewerb(14, "So", 1, "", "Dressurpferdprüfung Kl. L (Aufg. DPL1) - 5-6j. Pferde", Sparte.DRESSUR, "DP-L"),
Bewerb(10, "So", 1, "", "Pony Dressurprüfung Kl. L (Aufg. P6)", Sparte.DRESSUR, "L"),
Bewerb(
11,
"So",
1,
"",
"Dressurreiterprüfung Kl. L (Aufg. DRL1) - Abt. 1: R1/RD1 / Abt. 2: R2/RD2+",
Sparte.DRESSUR,
"L"
),
Bewerb(
12,
"So",
1,
"",
"Dressurprüfung Kl. L (Aufg. L3) - Abt. 1: R1/RD1 / Abt. 2: R2/RD2+",
Sparte.DRESSUR,
"L"
),
)
}
}
val isEmailValid = email.contains("@") && email.contains(".")
val canSubmit = vorname.isNotBlank() && nachname.isNotBlank() && isEmailValid
val canSubmit =
vorname.isNotBlank() && nachname.isNotBlank() && isEmailValid && pferdName.isNotBlank() && ausgewaehlteBewerbe.isNotEmpty()
Box(
modifier = Modifier.fillMaxSize().background(Color(0xFFF8F9FA)),
contentAlignment = Alignment.Center
BoxWithConstraints(
modifier = Modifier.fillMaxSize().background(Color(0xFFF0F2F5)),
contentAlignment = Alignment.TopCenter
) {
val isMobile = maxWidth < 600.dp
Column(
modifier = Modifier
.widthIn(max = 800.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(if (isMobile) 4.dp else 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Card(
modifier = Modifier.width(400.dp).padding(16.dp),
shape = RoundedCornerShape(20.dp),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(if (isMobile) 0.dp else 16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
elevation = CardDefaults.cardElevation(defaultElevation = if (isMobile) 2.dp else 6.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(if (isMobile) 16.dp else 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Hallo Du! 👋",
style = MaterialTheme.typography.headlineMedium,
text = if (turnierNr == "26128") "Online-Nennung: Springturnier Neumarkt" else "Online-Nennung: Dressurturnier Neumarkt",
style = if (isMobile) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.ExtraBold,
color = AppColors.Primary
)
Text(
text = "Lass uns Plan-B testen. Turnier: $turnierNr",
style = MaterialTheme.typography.bodyMedium,
text = "Turnier-Nr: $turnierNr | Datum: ${if (turnierNr == "26128") "25. April 2026" else "26. April 2026"}",
style = MaterialTheme.typography.titleMedium,
color = Color.Gray
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), thickness = 1.dp, color = Color.LightGray)
Text("Reiter & Kontakt", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
if (isMobile) {
OutlinedTextField(
value = vorname,
onValueChange = { vorname = it },
label = { Text("Vorname") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
label = { Text("Vorname*") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
OutlinedTextField(
value = nachname,
onValueChange = { nachname = it },
label = { Text("Nachname") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
label = { Text("Nachname*") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
} else {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = vorname,
onValueChange = { vorname = it },
label = { Text("Vorname*") },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
OutlinedTextField(
value = nachname,
onValueChange = { nachname = it },
label = { Text("Nachname*") },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
}
}
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-Mail Adresse") },
singleLine = true,
label = { Text("E-Mail Adresse* (für Bestätigung)") },
isError = email.isNotEmpty() && !isEmailValid,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
OutlinedTextField(
value = telefon,
onValueChange = { telefon = it },
label = { Text("Telefon-Nr.") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
Spacer(Modifier.height(4.dp))
Text("Pferd", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = pferdName,
onValueChange = { pferdName = it },
label = { Text("Pferdename / Kopfnummer*") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
)
Spacer(Modifier.height(8.dp))
Text("Bewerbe auswählen*", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
bewerbeListe.forEach { bewerb ->
val selected = ausgewaehlteBewerbe.contains(bewerb)
val parts = bewerb.name.split(" - ", limit = 2)
val mainName = parts[0]
val abteilung = if (parts.size > 1) parts[1] else ""
Surface(
color = if (selected) AppColors.PrimaryContainer.copy(alpha = 0.7f) else Color.Transparent,
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
) {
Row(
modifier = Modifier
.clickable {
if (selected) ausgewaehlteBewerbe.remove(bewerb)
else ausgewaehlteBewerbe.add(bewerb)
}
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selected,
onCheckedChange = { checked ->
if (checked == true) ausgewaehlteBewerbe.add(bewerb)
else ausgewaehlteBewerbe.remove(bewerb)
},
colors = CheckboxDefaults.colors(checkedColor = AppColors.Primary)
)
Spacer(Modifier.width(8.dp))
Column {
Text(
"${bewerb.nr}. $mainName",
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
fontSize = if (isMobile) 14.sp else 16.sp
)
if (abteilung.isNotBlank()) {
Text(
abteilung,
style = MaterialTheme.typography.bodySmall,
fontSize = if (isMobile) 11.sp else 12.sp,
color = if (selected) Color.Black.copy(alpha = 0.8f) else Color.Gray,
modifier = Modifier.padding(start = if (isMobile) 8.dp else 12.dp)
)
}
}
}
}
}
if (ausgewaehlteBewerbe.size > 3) {
Text(
"⚠️ Hinweis: Ein Pferd darf maximal 3x pro Tag starten.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 4.dp)
)
}
Spacer(Modifier.height(8.dp))
Text("Wünsche / Anmerkungen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = bemerkungen,
onValueChange = { bemerkungen = it },
placeholder = { Text("z.B. Startzeit-Wünsche, Stallnachbarn...") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
shape = RoundedCornerShape(12.dp)
)
Spacer(Modifier.height(24.dp))
Button(
onClick = {
// Wir füllen den Rest mit Dummy-Daten für den Test
val payload = NennungPayload(
vorname = vorname,
nachname = nachname,
lizenz = "Lizenzfrei",
pferdName = "Test-Pferd (Plan-B)",
pferdAlter = "2020",
lizenz = "N/A",
pferdName = pferdName,
pferdAlter = "N/A",
email = email,
telefon = "0123456789",
bewerbe = listOf(Bewerb(1, "Tag 1", 1, "08:00", "Test-Bewerb", Sparte.SPRINGEN, "A")),
bemerkungen = "Dies ist ein automatischer Test für Plan-B."
telefon = telefon,
bewerbe = ausgewaehlteBewerbe.toList(),
bemerkungen = bemerkungen
)
onNennenAbgeschickt(payload)
},
enabled = canSubmit,
modifier = Modifier.fillMaxWidth().height(50.dp),
modifier = Modifier.fillMaxWidth().height(if (isMobile) 56.dp else 64.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Primary)
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFFBF00),
disabledContainerColor = Color(0xFFFFBF00).copy(alpha = 0.4f)
),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp, pressedElevation = 8.dp)
) {
Text("Jetzt schicken!", fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(
"Jetzt nennen",
fontWeight = FontWeight.ExtraBold,
fontSize = if (isMobile) 18.sp else 20.sp,
color = if (canSubmit) Color.Black else Color.DarkGray
)
}
TextButton(onClick = onBack) {
Text("Zurück", color = Color.Gray)
Text(
text = "Mit dem Absenden akzeptiere ich die Speicherung meiner Daten für die Turnierabwicklung.\nSchutz gegen automatisierte Eingaben ist aktiv.",
style = MaterialTheme.typography.labelSmall,
color = Color.Gray,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
lineHeight = 16.sp
)
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text("Abbrechen", color = Color.Gray, fontSize = 16.sp)
}
}
}
}
}
}
@@ -120,12 +120,12 @@ fun LandingPage(
listOf(
VeranstaltungWebModel(
id = 1,
name = "CSN-B* Neumarkt",
ort = "Neumarkt am Wallersee",
datum = "24. - 26. April 2026",
name = "Turniere in Neumarkt",
ort = "Reitanlage Stroblmair",
datum = "25. - 26. April 2026",
turniere = listOf(
TurnierWebModel(101, "Springturnier Neumarkt", "Ausschreibung_Neumarkt.pdf"),
TurnierWebModel(102, "Dressurturnier Neumarkt", "Ausschreibung_Dressur.pdf")
TurnierWebModel(26128, "Springturnier (CSN-C NEU)", "26128.pdf"),
TurnierWebModel(26129, "Dressurturnier (CDN-C NEU)", "26129.pdf")
)
)
)
@@ -207,10 +207,41 @@ fun TurnierCardWeb(
turnier: TurnierWebModel,
onNennenClick: () -> Unit
) {
BoxWithConstraints {
val isMobile = maxWidth < 500.dp
OutlinedCard(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
colors = CardDefaults.outlinedCardColors(containerColor = AppColors.BackgroundLight)
) {
if (isMobile) {
Column(modifier = Modifier.padding(12.dp)) {
Text(turnier.name, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(
onClick = { /* PDF öffnen Logik */ },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Description, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Ausschreibung")
}
Button(
onClick = onNennenClick,
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Success),
modifier = Modifier.weight(1f)
) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Nennen")
}
}
}
} else {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -238,6 +269,8 @@ fun TurnierCardWeb(
}
}
}
}
}
}
@Composable