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:
+254
-164
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user