feat(mail-service): introduce persistence and REST support for Nennungen

- Added `NennungRepository` with methods for saving, updating status, and retrieving entries.
- Created `NennungController` to expose REST endpoints for Nennungen.
- Defined `NennungTable` schema with relevant fields and indices.
- Extended `MailPollingService` to parse incoming emails into `NennungEntity` and persist them.
- Updated `build.gradle.kts` with database dependencies and H2 configuration for local dev.
- Refined frontend layout in `OnlineNennungFormular` for improved usability and responsiveness.
This commit is contained in:
2026-04-14 16:50:24 +02:00
parent 4de44623c2
commit da3b57a91d
10 changed files with 662 additions and 257 deletions
@@ -1,18 +1,37 @@
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.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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
import at.mocode.frontend.features.nennung.domain.Bewerb
import at.mocode.frontend.features.nennung.domain.NennungMockData
data class NennungPayload(
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val telefon: String,
val bewerbe: List<Bewerb>,
val bemerkungen: String
)
@Composable
fun OnlineNennungFormular(
turnierNr: String,
@@ -42,180 +61,251 @@ fun OnlineNennungFormular(
ausgewaehlteBewerbe.isNotEmpty() &&
dsgvoAkzeptiert
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text("Online-Nennung für Turnier $turnierNr", style = MaterialTheme.typography.headlineSmall)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
}
// Reiter Daten
item {
Text("Reiter", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = vorname,
onValueChange = { vorname = it },
label = { Text("Vorname *") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = nachname,
onValueChange = { nachname = it },
label = { Text("Nachname *") },
modifier = Modifier.weight(1f)
)
}
}
item {
Text("Lizenz", style = MaterialTheme.typography.titleSmall)
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
Text(lizenz)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
lizenzen.forEach { l ->
DropdownMenuItem(
text = { Text(l) },
onClick = { lizenz = l; expanded = false }
)
}
}
}
}
// Pferd Daten
item {
Text("Pferd", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = pferdName,
onValueChange = { pferdName = it },
label = { Text("Pferd-Name oder Kopfnummer *") },
modifier = Modifier.fillMaxWidth()
)
}
item {
Text("Geburtsjahr Pferd", style = MaterialTheme.typography.titleSmall)
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
Text(pferdAlter)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
jahre.forEach { j ->
DropdownMenuItem(
text = { Text(j) },
onClick = { pferdAlter = j; expanded = false }
)
}
}
}
}
// Kontakt
item {
Text("Kontakt", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-Mail Adresse *") },
modifier = Modifier.fillMaxWidth(),
isError = email.isNotBlank() && !isEmailValid
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = telefon,
onValueChange = { telefon = it },
label = { Text("Telefonnummer (optional)") },
modifier = Modifier.fillMaxWidth()
)
}
// Bewerbe
item {
Text("Bewerbe / Prüfungen * (Mind. 1 wählen)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
NennungMockData.bewerbe.forEach { bewerb ->
val isSelected = ausgewaehlteBewerbe.any { it.nr == bewerb.nr }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable {
if (isSelected) {
val item = ausgewaehlteBewerbe.find { it.nr == bewerb.nr }
if (item != null) ausgewaehlteBewerbe.remove(item)
} else {
ausgewaehlteBewerbe.add(bewerb)
}
}.padding(vertical = 4.dp)
) {
Checkbox(checked = isSelected, onCheckedChange = null)
Spacer(Modifier.width(8.dp))
Text("Bewerb ${bewerb.nr}: ${bewerb.name} (${bewerb.tag})")
}
}
}
// Wünsche
item {
OutlinedTextField(
value = bemerkungen,
onValueChange = { bemerkungen = it },
label = { Text("Bemerkungen / Wünsche") },
modifier = Modifier.fillMaxWidth().height(100.dp),
maxLines = 4
)
}
// DSGVO
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = dsgvoAkzeptiert, onCheckedChange = { dsgvoAkzeptiert = it })
// Clean-White Layout: Hintergrund hellgrau, Formular in weißen Cards
Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF8F9FA))) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Spacer(Modifier.height(32.dp))
Text(
"Ich stimme zu, dass meine Daten zum Zweck der Nennung verarbeitet werden.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 8.dp)
text = "Turnier Online-Nennung",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF2D3436)
)
Text(
text = "Turnier-Nr: $turnierNr",
style = MaterialTheme.typography.bodyLarge,
color = Color.Gray,
modifier = Modifier.padding(bottom = 24.dp)
)
}
}
// Buttons
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(vertical = 16.dp)) {
OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) {
Text("Abbrechen")
// --- REITER CARD ---
item {
FormCard("Persönliche Daten (Reiter)") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ModernTextField(vorname, { vorname = it }, "Vorname *", Modifier.weight(1f))
ModernTextField(nachname, { nachname = it }, "Nachname *", Modifier.weight(1f))
}
Text("Lizenzklasse", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
DropdownSelector(lizenz, lizenzen) { lizenz = it }
}
}
Button(
onClick = {
onNennenAbgeschickt(
NennungPayload(
vorname, nachname, lizenz, pferdName, pferdAlter,
email, telefon, ausgewaehlteBewerbe.toList(), bemerkungen
)
}
// --- PFERD CARD ---
item {
FormCard("Pferdedaten") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
ModernTextField(pferdName, { pferdName = it }, "Name oder Kopfnummer *")
Text("Geburtsjahr", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
DropdownSelector(pferdAlter, jahre) { pferdAlter = it }
}
}
}
// --- KONTAKT CARD ---
item {
FormCard("Kontakt für Rückfragen") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
ModernTextField(
value = email,
onValueChange = { email = it },
label = "E-Mail Adresse *",
isError = email.isNotBlank() && !isEmailValid
)
},
enabled = canSubmit,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Success)
ModernTextField(telefon, { telefon = it }, "Telefonnummer (optional)")
}
}
}
// --- BEWERBE CARD ---
item {
FormCard("Bewerbe & Prüfungen") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
NennungMockData.bewerbe.forEach { bewerb ->
val isSelected = ausgewaehlteBewerbe.any { it.nr == bewerb.nr }
BewerbRow(bewerb, isSelected) {
if (isSelected) {
val item = ausgewaehlteBewerbe.find { it.nr == bewerb.nr }
if (item != null) ausgewaehlteBewerbe.remove(item)
} else {
ausgewaehlteBewerbe.add(bewerb)
}
}
}
}
}
}
// --- WÜNSCHE CARD ---
item {
FormCard("Anmerkungen") {
OutlinedTextField(
value = bemerkungen,
onValueChange = { bemerkungen = it },
placeholder = { Text("Besondere Wünsche, Stallplaketten, etc.") },
modifier = Modifier.fillMaxWidth().height(120.dp),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AppColors.Primary,
unfocusedBorderColor = Color(0xFFE0E0E0)
)
)
}
}
// --- DSGVO & ABSCHLUSS ---
item {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Jetzt Nennen")
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { dsgvoAkzeptiert = !dsgvoAkzeptiert }.padding(8.dp)
) {
Checkbox(checked = dsgvoAkzeptiert, onCheckedChange = { dsgvoAkzeptiert = it })
Spacer(Modifier.width(8.dp))
Text(
"Ich akzeptiere die Datenschutzbestimmungen.",
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(Modifier.height(16.dp))
Button(
onClick = {
onNennenAbgeschickt(
NennungPayload(
vorname, nachname, lizenz, pferdName, pferdAlter,
email, telefon, ausgewaehlteBewerbe.toList(), bemerkungen
)
)
},
enabled = canSubmit,
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (canSubmit) Color(0xFF2ECC71) else Color(0xFFBDC3C7)
)
) {
Text("JETZT NENNEN", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
TextButton(onClick = onBack, modifier = Modifier.padding(top = 8.dp)) {
Text("Abbrechen", color = Color.Gray)
}
Spacer(Modifier.height(48.dp))
}
}
}
}
}
data class NennungPayload(
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val telefon: String,
val bewerbe: List<Bewerb>,
val bemerkungen: String
)
@Composable
fun FormCard(title: String, content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = AppColors.Primary,
modifier = Modifier.padding(bottom = 16.dp)
)
content()
}
}
}
@Composable
fun ModernTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
isError: Boolean = false
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
isError = isError,
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AppColors.Primary,
unfocusedBorderColor = Color(0xFFE0E0E0),
errorBorderColor = Color.Red
)
)
}
@Composable
fun DropdownSelector(current: String, options: List<String>, onSelect: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Black),
border = ButtonDefaults.outlinedButtonBorder.copy(brush = androidx.compose.ui.graphics.SolidColor(Color(0xFFE0E0E0)))
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(current)
Icon(Icons.Default.Info, null, modifier = Modifier.size(18.dp), tint = Color.LightGray)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { opt ->
DropdownMenuItem(text = { Text(opt) }, onClick = { onSelect(opt); expanded = false })
}
}
}
}
@Composable
fun BewerbRow(bewerb: Bewerb, isSelected: Boolean, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
color = if (isSelected) Color(0xFFE8F5E9) else Color(0xFFF5F5F5),
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp)
) {
Checkbox(checked = isSelected, onCheckedChange = null)
Spacer(Modifier.width(12.dp))
Column {
Text(
"Bewerb ${bewerb.nr}: ${bewerb.name}",
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
Text(
bewerb.tag,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}