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
@@ -11,6 +11,7 @@ springBoot {
dependencies { dependencies {
// Interne Module // Interne Module
implementation(platform(projects.platform.platformBom))
implementation(projects.platform.platformDependencies) implementation(projects.platform.platformDependencies)
implementation(projects.core.coreUtils) implementation(projects.core.coreUtils)
implementation(projects.core.coreDomain) implementation(projects.core.coreDomain)
@@ -20,7 +21,20 @@ dependencies {
implementation(libs.spring.boot.starter.validation) implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator) implementation(libs.spring.boot.starter.actuator)
implementation(libs.spring.boot.starter.mail) implementation(libs.spring.boot.starter.mail)
implementation(libs.spring.boot.starter.jdbc)
implementation(libs.jackson.module.kotlin) implementation(libs.jackson.module.kotlin)
implementation(libs.jackson.datatype.jsr310)
// Database & Exposed
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.java.time)
implementation(libs.exposed.json)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.h2.driver)
implementation(libs.postgresql.driver)
implementation(libs.hikari.cp)
implementation(libs.spring.cloud.starter.consul.discovery) implementation(libs.spring.cloud.starter.consul.discovery)
implementation(libs.micrometer.tracing.bridge.brave) implementation(libs.micrometer.tracing.bridge.brave)
implementation(libs.zipkin.reporter.brave) implementation(libs.zipkin.reporter.brave)
@@ -1,22 +1,36 @@
package at.mocode.mail.service package at.mocode.mail.service
import at.mocode.mail.service.persistence.NennungEntity
import at.mocode.mail.service.persistence.NennungRepository
import at.mocode.mail.service.persistence.NennungTable
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.mail.Flags import jakarta.mail.Flags
import jakarta.mail.Folder import jakarta.mail.Folder
import jakarta.mail.Session import jakarta.mail.Session
import jakarta.mail.internet.InternetAddress import jakarta.mail.internet.InternetAddress
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.springframework.transaction.annotation.Transactional
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.mail.SimpleMailMessage import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.javamail.JavaMailSender import org.springframework.mail.javamail.JavaMailSender
import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.* import java.util.*
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
@Service @Service
@EnableScheduling @EnableScheduling
class MailPollingService( class MailPollingService(
private val mailSender: JavaMailSender, private val mailSender: JavaMailSender,
private val nennungRepository: NennungRepository,
private val objectMapper: ObjectMapper,
@Value("\${spring.mail.host}") private val imapHost: String, @Value("\${spring.mail.host}") private val imapHost: String,
@Value("\${spring.mail.port}") private val imapPort: Int, @Value("\${spring.mail.port}") private val imapPort: Int,
@Value("\${spring.mail.username}") private val username: String, @Value("\${spring.mail.username}") private val username: String,
@@ -24,6 +38,15 @@ class MailPollingService(
) { ) {
private val logger = LoggerFactory.getLogger(MailPollingService::class.java) private val logger = LoggerFactory.getLogger(MailPollingService::class.java)
@EventListener(ApplicationReadyEvent::class)
@Transactional
fun initSchema() {
transaction {
SchemaUtils.create(NennungTable)
}
logger.info("Datenbankschema für Mail-Service initialisiert.")
}
@Scheduled(fixedDelay = 60000) // Alle 60 Sekunden pollen @Scheduled(fixedDelay = 60000) // Alle 60 Sekunden pollen
fun pollMails() { fun pollMails() {
if (password.isBlank()) { if (password.isBlank()) {
@@ -61,10 +84,33 @@ class MailPollingService(
if (turnierNr != null) { if (turnierNr != null) {
logger.info("Nennung für Turnier $turnierNr erkannt.") logger.info("Nennung für Turnier $turnierNr erkannt.")
// TODO: Payload parsen und in DB speichern (Tenant-Routing)
try {
val content = message.content.toString()
val entity = NennungEntity(
id = Uuid.random(),
turnierNr = turnierNr,
status = "NEU",
vorname = extractValue(content, "Vorname") ?: "Unbekannt",
nachname = extractValue(content, "Nachname") ?: "Unbekannt",
lizenz = extractValue(content, "Lizenz") ?: "LF",
pferdName = extractValue(content, "Pferd") ?: "Unbekannt",
pferdAlter = extractValue(content, "Alter") ?: "2020",
email = (message.from?.firstOrNull() as? InternetAddress)?.address ?: "unbekannt@test.at",
telefon = extractValue(content, "Telefon"),
bewerbe = extractValue(content, "Bewerbe") ?: "[]",
bemerkungen = extractValue(content, "Bemerkungen")
)
nennungRepository.save(entity)
logger.info("Nennung für ${entity.vorname} ${entity.nachname} erfolgreich persistiert.")
// Auto-Reply senden // Auto-Reply senden
sendAutoReply(message.from?.firstOrNull()?.toString() ?: "", turnierNr) sendAutoReply(entity.email, turnierNr)
} catch (e: Exception) {
logger.error("Fehler beim Parsen/Speichern der Nennung: ${e.message}")
}
// Mail als gelesen markieren // Mail als gelesen markieren
message.setFlag(Flags.Flag.SEEN, true) message.setFlag(Flags.Flag.SEEN, true)
@@ -87,6 +133,11 @@ class MailPollingService(
return match?.groupValues?.get(1) return match?.groupValues?.get(1)
} }
private fun extractValue(content: String, key: String): String? {
val regex = Regex("$key:\\s*(.*)")
return regex.find(content)?.groupValues?.get(1)?.trim()
}
private fun sendAutoReply(to: String, turnierNr: String) { private fun sendAutoReply(to: String, turnierNr: String) {
try { try {
val message = SimpleMailMessage() val message = SimpleMailMessage()
@@ -0,0 +1,29 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.mail.service
import at.mocode.mail.service.persistence.NennungEntity
import at.mocode.mail.service.persistence.NennungRepository
import org.springframework.web.bind.annotation.*
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@RestController
@RequestMapping("/api/mail/nennungen")
class NennungController(
private val nennungRepository: NennungRepository
) {
@GetMapping
fun getAllNennungen(): List<NennungEntity> {
return nennungRepository.findAll()
}
@PutMapping("/{id}/status")
fun updateStatus(
@PathVariable id: String,
@RequestBody newStatus: String
) {
nennungRepository.updateStatus(Uuid.parse(id), newStatus)
}
}
@@ -0,0 +1,81 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.mail.service.persistence
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.springframework.stereotype.Repository
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.springframework.transaction.annotation.Transactional
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
data class NennungEntity(
val id: Uuid,
val turnierNr: String,
val status: String,
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val telefon: String?,
val bewerbe: String,
val bemerkungen: String?
)
@Repository
@Transactional
class NennungRepository {
fun save(nennung: NennungEntity) {
transaction {
NennungTable.insert {
it[id] = nennung.id
it[turnierNr] = nennung.turnierNr
it[status] = nennung.status
it[vorname] = nennung.vorname
it[nachname] = nennung.nachname
it[lizenz] = nennung.lizenz
it[pferdName] = nennung.pferdName
it[pferdAlter] = nennung.pferdAlter
it[email] = nennung.email
it[telefon] = nennung.telefon
it[bewerbe] = nennung.bewerbe
it[bemerkungen] = nennung.bemerkungen
}
}
}
fun updateStatus(id: Uuid, newStatus: String) {
transaction {
NennungTable.update({ NennungTable.id eq id }) {
it[status] = newStatus
}
}
}
fun findAll(): List<NennungEntity> {
return transaction {
NennungTable.selectAll().map {
NennungEntity(
id = it[NennungTable.id],
turnierNr = it[NennungTable.turnierNr],
status = it[NennungTable.status],
vorname = it[NennungTable.vorname],
nachname = it[NennungTable.nachname],
lizenz = it[NennungTable.lizenz],
pferdName = it[NennungTable.pferdName],
pferdAlter = it[NennungTable.pferdAlter],
email = it[NennungTable.email],
telefon = it[NennungTable.telefon],
bewerbe = it[NennungTable.bewerbe],
bemerkungen = it[NennungTable.bemerkungen]
)
}
}
}
}
@@ -0,0 +1,34 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.mail.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.CurrentTimestamp
import org.jetbrains.exposed.v1.javatime.timestamp
import kotlin.uuid.ExperimentalUuidApi
object NennungTable : Table("nennungen") {
val id = uuid("id")
val turnierNr = varchar("turnier_nr", 20)
val status = varchar("status", 20) // NEU, GELESEN, UEBERNOMMEN
val eingangsdatum = timestamp("eingangsdatum").defaultExpression(CurrentTimestamp)
// Reiter Daten
val vorname = varchar("vorname", 100)
val nachname = varchar("nachname", 100)
val lizenz = varchar("lizenz", 50)
// Pferd Daten
val pferdName = varchar("pferd_name", 100)
val pferdAlter = varchar("pferd_alter", 10)
// Kontakt
val email = varchar("email", 150)
val telefon = varchar("telefon", 50).nullable()
// Payload (Bewerbe & Bemerkungen)
val bewerbe = text("bewerbe") // Kommagetrennte Liste der Bewerbs-Nummern
val bemerkungen = text("bemerkungen").nullable()
override val primaryKey = PrimaryKey(id)
}
@@ -1,6 +1,15 @@
spring: spring:
application: application:
name: mail-service name: mail-service
datasource:
url: jdbc:h2:mem:maildb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password: ""
h2:
console:
enabled: true
path: /h2-console
mail: mail:
host: ${MAIL_HOST:imap.world4you.com} host: ${MAIL_HOST:imap.world4you.com}
port: ${MAIL_PORT:993} port: ${MAIL_PORT:993}
@@ -1,18 +1,37 @@
package at.mocode.frontend.features.nennung.presentation.web package at.mocode.frontend.features.nennung.presentation.web
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.AppColors import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.features.nennung.domain.Bewerb import at.mocode.frontend.features.nennung.domain.Bewerb
import at.mocode.frontend.features.nennung.domain.NennungMockData 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 @Composable
fun OnlineNennungFormular( fun OnlineNennungFormular(
turnierNr: String, turnierNr: String,
@@ -42,152 +61,126 @@ fun OnlineNennungFormular(
ausgewaehlteBewerbe.isNotEmpty() && ausgewaehlteBewerbe.isNotEmpty() &&
dsgvoAkzeptiert dsgvoAkzeptiert
// Clean-White Layout: Hintergrund hellgrau, Formular in weißen Cards
Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF8F9FA))) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) horizontalAlignment = Alignment.CenterHorizontally
) { ) {
item { item {
Text("Online-Nennung für Turnier $turnierNr", style = MaterialTheme.typography.headlineSmall) Spacer(Modifier.height(32.dp))
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) Text(
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)
)
} }
// Reiter Daten // --- REITER CARD ---
item { item {
Text("Reiter", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) FormCard("Persönliche Daten (Reiter)") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField( ModernTextField(vorname, { vorname = it }, "Vorname *", Modifier.weight(1f))
value = vorname, ModernTextField(nachname, { nachname = it }, "Nachname *", Modifier.weight(1f))
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("Lizenzklasse", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
Text("Lizenz", style = MaterialTheme.typography.titleSmall) DropdownSelector(lizenz, lizenzen) { lizenz = it }
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 // --- PFERD CARD ---
item { item {
Text("Pferd", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) FormCard("Pferdedaten") {
OutlinedTextField( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
value = pferdName, ModernTextField(pferdName, { pferdName = it }, "Name oder Kopfnummer *")
onValueChange = { pferdName = it },
label = { Text("Pferd-Name oder Kopfnummer *") },
modifier = Modifier.fillMaxWidth()
)
}
item { Text("Geburtsjahr", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
Text("Geburtsjahr Pferd", style = MaterialTheme.typography.titleSmall) DropdownSelector(pferdAlter, jahre) { pferdAlter = it }
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 // --- KONTAKT CARD ---
item { item {
Text("Kontakt", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) FormCard("Kontakt für Rückfragen") {
OutlinedTextField( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
ModernTextField(
value = email, value = email,
onValueChange = { email = it }, onValueChange = { email = it },
label = { Text("E-Mail Adresse *") }, label = "E-Mail Adresse *",
modifier = Modifier.fillMaxWidth(),
isError = email.isNotBlank() && !isEmailValid isError = email.isNotBlank() && !isEmailValid
) )
Spacer(Modifier.height(8.dp)) ModernTextField(telefon, { telefon = it }, "Telefonnummer (optional)")
OutlinedTextField( }
value = telefon, }
onValueChange = { telefon = it },
label = { Text("Telefonnummer (optional)") },
modifier = Modifier.fillMaxWidth()
)
} }
// Bewerbe // --- BEWERBE CARD ---
item { item {
Text("Bewerbe / Prüfungen * (Mind. 1 wählen)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) FormCard("Bewerbe & Prüfungen") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
NennungMockData.bewerbe.forEach { bewerb -> NennungMockData.bewerbe.forEach { bewerb ->
val isSelected = ausgewaehlteBewerbe.any { it.nr == bewerb.nr } val isSelected = ausgewaehlteBewerbe.any { it.nr == bewerb.nr }
Row( BewerbRow(bewerb, isSelected) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable {
if (isSelected) { if (isSelected) {
val item = ausgewaehlteBewerbe.find { it.nr == bewerb.nr } val item = ausgewaehlteBewerbe.find { it.nr == bewerb.nr }
if (item != null) ausgewaehlteBewerbe.remove(item) if (item != null) ausgewaehlteBewerbe.remove(item)
} else { } else {
ausgewaehlteBewerbe.add(bewerb) 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 // --- WÜNSCHE CARD ---
item { item {
FormCard("Anmerkungen") {
OutlinedTextField( OutlinedTextField(
value = bemerkungen, value = bemerkungen,
onValueChange = { bemerkungen = it }, onValueChange = { bemerkungen = it },
label = { Text("Bemerkungen / Wünsche") }, placeholder = { Text("Besondere Wünsche, Stallplaketten, etc.") },
modifier = Modifier.fillMaxWidth().height(100.dp), modifier = Modifier.fillMaxWidth().height(120.dp),
maxLines = 4 shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AppColors.Primary,
unfocusedBorderColor = Color(0xFFE0E0E0)
)
) )
} }
}
// DSGVO // --- DSGVO & ABSCHLUSS ---
item { item {
Row(verticalAlignment = Alignment.CenterVertically) { Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { dsgvoAkzeptiert = !dsgvoAkzeptiert }.padding(8.dp)
) {
Checkbox(checked = dsgvoAkzeptiert, onCheckedChange = { dsgvoAkzeptiert = it }) Checkbox(checked = dsgvoAkzeptiert, onCheckedChange = { dsgvoAkzeptiert = it })
Spacer(Modifier.width(8.dp))
Text( Text(
"Ich stimme zu, dass meine Daten zum Zweck der Nennung verarbeitet werden.", "Ich akzeptiere die Datenschutzbestimmungen.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium
modifier = Modifier.padding(start = 8.dp)
) )
} }
}
// Buttons Spacer(Modifier.height(16.dp))
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(vertical = 16.dp)) {
OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) {
Text("Abbrechen")
}
Button( Button(
onClick = { onClick = {
onNennenAbgeschickt( onNennenAbgeschickt(
@@ -198,24 +191,121 @@ fun OnlineNennungFormular(
) )
}, },
enabled = canSubmit, enabled = canSubmit,
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Success) shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (canSubmit) Color(0xFF2ECC71) else Color(0xFFBDC3C7)
)
) { ) {
Text("Jetzt Nennen") 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( @Composable
val vorname: String, fun FormCard(title: String, content: @Composable () -> Unit) {
val nachname: String, Card(
val lizenz: String, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
val pferdName: String, shape = RoundedCornerShape(20.dp),
val pferdAlter: String, colors = CardDefaults.cardColors(containerColor = Color.White),
val email: String, elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
val telefon: String, ) {
val bewerbe: List<Bewerb>, Column(modifier = Modifier.padding(20.dp)) {
val bemerkungen: String 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
)
}
}
}
}
@@ -5,8 +5,6 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.singleWindowApplication import androidx.compose.ui.window.singleWindowApplication
import androidx.lifecycle.viewmodel.compose.viewModel
import at.mocode.desktop.v2.NennungsEingangScreen
/** /**
* Hot-Reload Preview Entry Point * Hot-Reload Preview Entry Point
@@ -4,11 +4,13 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -17,6 +19,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
data class OnlineNennungMail( data class OnlineNennungMail(
val id: String, val id: String,
@@ -24,18 +27,55 @@ data class OnlineNennungMail(
val empfaenger: String, val empfaenger: String,
val datum: String, val datum: String,
val turnierNr: String, val turnierNr: String,
val reiter: String, val vorname: String,
val nachname: String,
val lizenz: String,
val pferd: String, val pferd: String,
val pferdAlter: String,
val telefon: String?,
val bewerbe: String, val bewerbe: String,
val status: String = "Neu" val bemerkungen: String?,
var status: String = "NEU"
) )
@Composable @Composable
fun NennungsEingangScreen(onBack: () -> Unit) { fun NennungsEingangScreen(onBack: () -> Unit) {
DesktopThemeV2 { DesktopThemeV2 {
var mails by remember { mutableStateOf(getMockMails()) } var mails by remember { mutableStateOf<List<OnlineNennungMail>>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
var selectedMail by remember { mutableStateOf<OnlineNennungMail?>(null) }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
val filteredMails = remember(mails, searchQuery) {
if (searchQuery.isBlank()) mails
else mails.filter {
it.vorname.contains(searchQuery, ignoreCase = true) ||
it.nachname.contains(searchQuery, ignoreCase = true) ||
it.pferd.contains(searchQuery, ignoreCase = true) ||
it.turnierNr.contains(searchQuery, ignoreCase = true)
}
}
// Initiales Laden
LaunchedEffect(Unit) {
isRefreshing = true
delay(800)
mails = getMockMails()
isRefreshing = false
}
if (selectedMail != null) {
NennungDetailDialog(
mail = selectedMail!!,
onDismiss = { selectedMail = null },
onMarkProcessed = {
val updated = mails.map { if (it.id == selectedMail!!.id) it.copy(status = "GELESEN") else it }
mails = updated
selectedMail = null
}
)
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Header // Header
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -43,6 +83,7 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
Icon(Icons.Default.Email, null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary) Icon(Icons.Default.Email, null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary)
Text("Nennungs-Eingang (Online-Nennen)", style = MaterialTheme.typography.headlineMedium) Text("Nennungs-Eingang (Online-Nennen)", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (isRefreshing) CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
Button( Button(
onClick = { /* Refresh Logik */ }, onClick = { /* Refresh Logik */ },
enabled = !isRefreshing enabled = !isRefreshing
@@ -54,11 +95,22 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
} }
Text( Text(
"Hier werden alle eingegangenen Online-Nennungen angezeigt, die über das Web-Formular an meldestelle-[Nr]@mo-code.at gesendet wurden.", "Hier werden alle eingegangenen Online-Nennungen angezeigt. Klicke auf 'Anzeigen', um alle Details für die manuelle Übernahme zu sehen.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color.Gray color = Color.Gray
) )
// Suchfeld
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Suche nach Reiter, Pferd oder Turnier-Nr...") },
leadingIcon = { Icon(Icons.Default.Search, null) },
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
// Tabelle // Tabelle
Card(modifier = Modifier.fillMaxWidth().weight(1f)) { Card(modifier = Modifier.fillMaxWidth().weight(1f)) {
Column { Column {
@@ -67,43 +119,50 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant).padding(12.dp), Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant).padding(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Status", Modifier.width(80.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp)
Text("Datum", Modifier.width(150.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp) Text("Datum", Modifier.width(150.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp)
Text("Turnier", Modifier.width(100.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp) Text("Turnier", Modifier.width(80.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp)
Text("Reiter", Modifier.width(200.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp) Text("Reiter", Modifier.width(200.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp)
Text("Pferd", Modifier.width(200.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp) Text("Pferd", Modifier.width(200.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp)
Text("Bewerbe", Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 13.sp) Text("Bewerbe", Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 13.sp)
Text("Aktion", Modifier.width(150.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp) Text("Aktion", Modifier.width(120.dp), fontWeight = FontWeight.Bold, fontSize = 13.sp)
} }
HorizontalDivider() HorizontalDivider()
if (mails.isEmpty()) { if (filteredMails.isEmpty() && !isRefreshing) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine neuen Nennungen vorhanden.", color = Color.Gray) Text(
if (searchQuery.isBlank()) "Keine neuen Nennungen vorhanden."
else "Keine Nennungen für '$searchQuery' gefunden.",
color = Color.Gray
)
} }
} else { } else {
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
items(mails) { mail -> items(filteredMails) { mail ->
Row( Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Badge(
containerColor = if (mail.status == "NEU") Color(0xFFE74C3C) else Color(0xFFBDC3C7),
modifier = Modifier.width(80.dp).padding(end = 8.dp)
) {
Text(mail.status, color = Color.White, fontSize = 10.sp)
}
Text(mail.datum, Modifier.width(150.dp), fontSize = 13.sp) Text(mail.datum, Modifier.width(150.dp), fontSize = 13.sp)
Text(mail.turnierNr, Modifier.width(100.dp), fontSize = 13.sp, fontWeight = FontWeight.SemiBold) Text(mail.turnierNr, Modifier.width(80.dp), fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Text(mail.reiter, Modifier.width(200.dp), fontSize = 13.sp) Text("${mail.vorname} ${mail.nachname}", Modifier.width(200.dp), fontSize = 13.sp)
Text(mail.pferd, Modifier.width(200.dp), fontSize = 13.sp) Text(mail.pferd, Modifier.width(200.dp), fontSize = 13.sp)
Text(mail.bewerbe, Modifier.weight(1f), fontSize = 13.sp) Text(mail.bewerbe, Modifier.weight(1f), fontSize = 13.sp)
Row(Modifier.width(150.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button( Button(
onClick = { /* Übernahme Logik */ }, onClick = { selectedMail = mail },
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
modifier = Modifier.height(32.dp), modifier = Modifier.width(120.dp).height(32.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary) colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) { ) {
Icon(Icons.Default.Check, null, modifier = Modifier.size(14.dp)) Text("Anzeigen", fontSize = 11.sp)
Spacer(Modifier.width(4.dp))
Text("Übernehmen", fontSize = 11.sp)
}
} }
} }
HorizontalDivider(Modifier.padding(horizontal = 8.dp), thickness = 0.5.dp) HorizontalDivider(Modifier.padding(horizontal = 8.dp), thickness = 0.5.dp)
@@ -116,8 +175,48 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
} }
} }
private fun getMockMails() = listOf( @Composable
OnlineNennungMail("1", "max.mustermann@web.de", "meldestelle-26128@mo-code.at", "14.04.2026 14:30", "26128", "Max Mustermann", "Spirit", "1, 2, 5"), fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkProcessed: () -> Unit) {
OnlineNennungMail("2", "susi.sorglos@gmx.at", "meldestelle-26128@mo-code.at", "14.04.2026 15:12", "26128", "Susi Sorglos", "Flocke", "10, 11"), AlertDialog(
OnlineNennungMail("3", "info@reitstall-hofer.at", "meldestelle-26129@mo-code.at", "14.04.2026 16:05", "26129", "Georg Hofer", "Black Beauty", "3, 4, 8") onDismissRequest = onDismiss,
title = { Text("Details zur Online-Nennung") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
DetailRow("Absender", mail.sender)
DetailRow("Turnier", mail.turnierNr)
DetailRow("Eingang", mail.datum)
HorizontalDivider()
Text("Reiter: ${mail.vorname} ${mail.nachname} (${mail.lizenz})", fontWeight = FontWeight.Bold)
Text("Pferd: ${mail.pferd} (Geb. ${mail.pferdAlter})", fontWeight = FontWeight.Bold)
DetailRow("Telefon", mail.telefon ?: "-")
HorizontalDivider()
Text("Ausgewählte Bewerbe:", fontWeight = FontWeight.SemiBold)
Text(mail.bewerbe)
if (!mail.bemerkungen.isNullOrBlank()) {
Text("Bemerkungen:", fontWeight = FontWeight.SemiBold)
Text(mail.bemerkungen, color = Color.DarkGray)
}
}
},
confirmButton = {
Button(onClick = onMarkProcessed) { Text("Als gelesen markieren") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Schließen") }
}
)
}
@Composable
fun DetailRow(label: String, value: String) {
Row(Modifier.fillMaxWidth()) {
Text("$label: ", fontWeight = FontWeight.SemiBold, modifier = Modifier.width(100.dp))
Text(value)
}
}
private fun getMockMails() = listOf(
OnlineNennungMail("1", "max.mustermann@web.de", "meldestelle-26128@mo-code.at", "14.04.2026 14:30", "26128", "Max", "Mustermann", "R2", "Spirit", "2015", "0664/1234567", "1, 2, 5", "Brauche Box für Freitag"),
OnlineNennungMail("2", "susi.sorglos@gmx.at", "meldestelle-26128@mo-code.at", "14.04.2026 15:12", "26128", "Susi", "Sorglos", "LF", "Flocke", "2018", null, "10, 11", null),
OnlineNennungMail("3", "info@reitstall-hofer.at", "meldestelle-26129@mo-code.at", "14.04.2026 16:05", "26129", "Georg", "Hofer", "R3", "Black Beauty", "2012", "0676/9876543", "3, 4, 8", "Bitte späte Startzeit")
) )