feat(mail-service): initialize Mail-Service and integrate online nomination workflow

- Created `MailServiceApplication` with Spring Boot setup.
- Added `MailPollingService` for IMAP polling, `TurnierNr` extraction, and auto-reply functionality.
- Implemented structured email sending for online nominations via `OnlineNennungFormular`.
- Updated frontend with `Erfolgsscreen` for nomination confirmation and fallback handling.
- Added build configurations for Mail-Service and frontend nomination module.
- Documented phase-based roadmap for Online-Nennung and Mail-Service rollout.
This commit is contained in:
2026-04-14 14:59:11 +02:00
parent 5f87eed86a
commit adfa97978e
9 changed files with 497 additions and 5 deletions
@@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
/**
* Feature-Modul: Nennungs-Maske (Desktop-only)
* Kapselt die gesamte UI und Logik für die Nennungserfassung am Turnier.
* kapselt die gesamte UI und Logik für die Nennungserfassung am Turnier.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
@@ -0,0 +1,221 @@
package at.mocode.frontend.features.nennung.presentation.web
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.features.nennung.domain.Bewerb
import at.mocode.frontend.features.nennung.domain.NennungMockData
@Composable
fun OnlineNennungFormular(
turnierNr: String,
onNennenAbgeschickt: (NennungPayload) -> Unit,
onBack: () -> Unit
) {
var vorname by remember { mutableStateOf("") }
var nachname by remember { mutableStateOf("") }
var lizenz by remember { mutableStateOf("Lizenzfrei") }
var pferdName by remember { mutableStateOf("") }
var pferdAlter by remember { mutableStateOf("2020") }
var email by remember { mutableStateOf("") }
var telefon by remember { mutableStateOf("") }
var bemerkungen by remember { mutableStateOf("") }
var dsgvoAkzeptiert by remember { mutableStateOf(false) }
val ausgewaehlteBewerbe = remember { mutableStateListOf<Bewerb>() }
val lizenzen = listOf("Lizenzfrei", "R1", "R2", "R3", "R4", "RS1", "RS2")
val jahre = (2000..2022).map { it.toString() }.reversed()
val isEmailValid = email.contains("@") && email.contains(".")
val canSubmit = vorname.isNotBlank() &&
nachname.isNotBlank() &&
pferdName.isNotBlank() &&
isEmailValid &&
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 })
Text(
"Ich stimme zu, dass meine Daten zum Zweck der Nennung verarbeitet werden.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 8.dp)
)
}
}
// Buttons
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(vertical = 16.dp)) {
OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) {
Text("Abbrechen")
}
Button(
onClick = {
onNennenAbgeschickt(
NennungPayload(
vorname, nachname, lizenz, pferdName, pferdAlter,
email, telefon, ausgewaehlteBewerbe.toList(), bemerkungen
)
)
},
enabled = canSubmit,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Success)
) {
Text("Jetzt Nennen")
}
}
}
}
}
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
)