diff --git a/backend/services/mail/mail-service/build.gradle.kts b/backend/services/mail/mail-service/build.gradle.kts index 13dbdfbb..caea13fb 100644 --- a/backend/services/mail/mail-service/build.gradle.kts +++ b/backend/services/mail/mail-service/build.gradle.kts @@ -11,6 +11,7 @@ springBoot { dependencies { // Interne Module + implementation(platform(projects.platform.platformBom)) implementation(projects.platform.platformDependencies) implementation(projects.core.coreUtils) implementation(projects.core.coreDomain) @@ -20,7 +21,20 @@ dependencies { implementation(libs.spring.boot.starter.validation) implementation(libs.spring.boot.starter.actuator) implementation(libs.spring.boot.starter.mail) + implementation(libs.spring.boot.starter.jdbc) 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.micrometer.tracing.bridge.brave) implementation(libs.zipkin.reporter.brave) diff --git a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailPollingService.kt b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailPollingService.kt index 4da6beae..d1336679 100644 --- a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailPollingService.kt +++ b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailPollingService.kt @@ -1,99 +1,150 @@ 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.Folder import jakarta.mail.Session 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.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.javamail.JavaMailSender import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import java.util.* +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) @Service @EnableScheduling class MailPollingService( - private val mailSender: JavaMailSender, - @Value("\${spring.mail.host}") private val imapHost: String, - @Value("\${spring.mail.port}") private val imapPort: Int, - @Value("\${spring.mail.username}") private val username: String, - @Value("\${spring.mail.password}") private val password: String + private val mailSender: JavaMailSender, + private val nennungRepository: NennungRepository, + private val objectMapper: ObjectMapper, + @Value("\${spring.mail.host}") private val imapHost: String, + @Value("\${spring.mail.port}") private val imapPort: Int, + @Value("\${spring.mail.username}") private val username: String, + @Value("\${spring.mail.password}") private val password: String ) { - private val logger = LoggerFactory.getLogger(MailPollingService::class.java) + private val logger = LoggerFactory.getLogger(MailPollingService::class.java) - @Scheduled(fixedDelay = 60000) // Alle 60 Sekunden pollen - fun pollMails() { - if (password.isBlank()) { - logger.warn("Mail-Passwort nicht gesetzt. Polling übersprungen.") - return - } + @EventListener(ApplicationReadyEvent::class) + @Transactional + fun initSchema() { + transaction { + SchemaUtils.create(NennungTable) + } + logger.info("Datenbankschema für Mail-Service initialisiert.") + } - try { - val props = Properties() - props["mail.store.protocol"] = "imaps" - props["mail.imaps.host"] = imapHost - props["mail.imaps.port"] = imapPort.toString() - props["mail.imaps.ssl.enable"] = "true" + @Scheduled(fixedDelay = 60000) // Alle 60 Sekunden pollen + fun pollMails() { + if (password.isBlank()) { + logger.warn("Mail-Passwort nicht gesetzt. Polling übersprungen.") + return + } - val session = Session.getInstance(props) - val store = session.getStore("imaps") - store.connect(imapHost, username, password) + try { + val props = Properties() + props["mail.store.protocol"] = "imaps" + props["mail.imaps.host"] = imapHost + props["mail.imaps.port"] = imapPort.toString() + props["mail.imaps.ssl.enable"] = "true" - val inbox = store.getFolder("INBOX") - inbox.open(Folder.READ_WRITE) + val session = Session.getInstance(props) + val store = session.getStore("imaps") + store.connect(imapHost, username, password) - // Nur ungelesene Nachrichten - val messages = inbox.getMessages() - logger.info("Gefundene Nachrichten in INBOX: ${messages.size}") + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_WRITE) - for (message in messages) { - if (!message.isSet(Flags.Flag.SEEN)) { - val recipients = message.getRecipients(jakarta.mail.Message.RecipientType.TO) - val toAddress = (recipients?.firstOrNull() as? InternetAddress)?.address ?: "" + // Nur ungelesene Nachrichten + val messages = inbox.getMessages() + logger.info("Gefundene Nachrichten in INBOX: ${messages.size}") - logger.info("Neue Mail empfangen von: ${message.from?.firstOrNull()} an: $toAddress") + for (message in messages) { + if (!message.isSet(Flags.Flag.SEEN)) { + val recipients = message.getRecipients(jakarta.mail.Message.RecipientType.TO) + val toAddress = (recipients?.firstOrNull() as? InternetAddress)?.address ?: "" - // Turnier-Nr extrahieren: meldestelle-26128@mo-code.at - val turnierNr = extractTurnierNr(toAddress) + logger.info("Neue Mail empfangen von: ${message.from?.firstOrNull()} an: $toAddress") - if (turnierNr != null) { - logger.info("Nennung für Turnier $turnierNr erkannt.") - // TODO: Payload parsen und in DB speichern (Tenant-Routing) + // Turnier-Nr extrahieren: meldestelle-26128@mo-code.at + val turnierNr = extractTurnierNr(toAddress) - // Auto-Reply senden - sendAutoReply(message.from?.firstOrNull()?.toString() ?: "", turnierNr) + if (turnierNr != null) { + logger.info("Nennung für Turnier $turnierNr erkannt.") - // Mail als gelesen markieren - message.setFlag(Flags.Flag.SEEN, true) - } else { - logger.warn("Keine Turnier-Nr in Adresse $toAddress gefunden. Mail wird ignoriert.") - } - } + 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 + sendAutoReply(entity.email, turnierNr) + } catch (e: Exception) { + logger.error("Fehler beim Parsen/Speichern der Nennung: ${e.message}") } - inbox.close(false) - store.close() - } catch (e: Exception) { - logger.error("Fehler beim Mail-Polling: ${e.message}", e) + // Mail als gelesen markieren + message.setFlag(Flags.Flag.SEEN, true) + } else { + logger.warn("Keine Turnier-Nr in Adresse $toAddress gefunden. Mail wird ignoriert.") + } } - } + } - private fun extractTurnierNr(address: String): String? { - val regex = Regex("meldestelle-(\\d+)@.*") - val match = regex.find(address) - return match?.groupValues?.get(1) + inbox.close(false) + store.close() + } catch (e: Exception) { + logger.error("Fehler beim Mail-Polling: ${e.message}", e) } + } - private fun sendAutoReply(to: String, turnierNr: String) { - try { - val message = SimpleMailMessage() - message.from = username - message.setTo(to) - message.subject = "Eingangsbestätigung: Ihre Nennung für Turnier $turnierNr" - message.text = """ + private fun extractTurnierNr(address: String): String? { + val regex = Regex("meldestelle-(\\d+)@.*") + val match = regex.find(address) + 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) { + try { + val message = SimpleMailMessage() + message.from = username + message.setTo(to) + message.subject = "Eingangsbestätigung: Ihre Nennung für Turnier $turnierNr" + message.text = """ Sehr geehrte Damen und Herren, vielen Dank für Ihre Online-Nennung für das Turnier $turnierNr. @@ -105,10 +156,10 @@ class MailPollingService( Ihre Turniermeldestelle """.trimIndent() - mailSender.send(message) - logger.info("Auto-Reply an $to für Turnier $turnierNr gesendet.") - } catch (e: Exception) { - logger.error("Fehler beim Senden des Auto-Replies: ${e.message}") - } + mailSender.send(message) + logger.info("Auto-Reply an $to für Turnier $turnierNr gesendet.") + } catch (e: Exception) { + logger.error("Fehler beim Senden des Auto-Replies: ${e.message}") } + } } diff --git a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailServiceApplication.kt b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailServiceApplication.kt index fd9aa92c..3fd84fd1 100644 --- a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailServiceApplication.kt +++ b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/MailServiceApplication.kt @@ -7,5 +7,5 @@ import org.springframework.boot.runApplication class MailServiceApplication fun main(args: Array) { - runApplication(*args) + runApplication(*args) } diff --git a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/NennungController.kt b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/NennungController.kt new file mode 100644 index 00000000..0d22b80e --- /dev/null +++ b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/NennungController.kt @@ -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 { + return nennungRepository.findAll() + } + + @PutMapping("/{id}/status") + fun updateStatus( + @PathVariable id: String, + @RequestBody newStatus: String + ) { + nennungRepository.updateStatus(Uuid.parse(id), newStatus) + } +} diff --git a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/persistence/NennungRepository.kt b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/persistence/NennungRepository.kt new file mode 100644 index 00000000..618486ec --- /dev/null +++ b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/persistence/NennungRepository.kt @@ -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 { + 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] + ) + } + } + } +} diff --git a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/persistence/NennungTable.kt b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/persistence/NennungTable.kt new file mode 100644 index 00000000..c938e8d1 --- /dev/null +++ b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/persistence/NennungTable.kt @@ -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) +} diff --git a/backend/services/mail/mail-service/src/main/resources/application.yaml b/backend/services/mail/mail-service/src/main/resources/application.yaml index 897129ae..368aad58 100644 --- a/backend/services/mail/mail-service/src/main/resources/application.yaml +++ b/backend/services/mail/mail-service/src/main/resources/application.yaml @@ -1,6 +1,15 @@ spring: application: 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: host: ${MAIL_HOST:imap.world4you.com} port: ${MAIL_PORT:993} diff --git a/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/presentation/web/OnlineNennungFormular.kt b/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/presentation/web/OnlineNennungFormular.kt index 94ad150f..07f80c41 100644 --- a/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/presentation/web/OnlineNennungFormular.kt +++ b/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/presentation/web/OnlineNennungFormular.kt @@ -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, + 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, - 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, 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 + ) + } + } + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/PreviewMain.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/PreviewMain.kt index c03eff29..3eea355f 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/PreviewMain.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/PreviewMain.kt @@ -5,8 +5,6 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.singleWindowApplication -import androidx.lifecycle.viewmodel.compose.viewModel -import at.mocode.desktop.v2.NennungsEingangScreen /** * Hot-Reload Preview Entry Point diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/NennungsEingangScreen.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/NennungsEingangScreen.kt index e1b52700..512b8d57 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/NennungsEingangScreen.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/NennungsEingangScreen.kt @@ -4,11 +4,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.* import androidx.compose.runtime.* 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.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay data class OnlineNennungMail( val id: String, @@ -24,18 +27,55 @@ data class OnlineNennungMail( val empfaenger: String, val datum: String, val turnierNr: String, - val reiter: String, + val vorname: String, + val nachname: String, + val lizenz: String, val pferd: String, + val pferdAlter: String, + val telefon: String?, val bewerbe: String, - val status: String = "Neu" + val bemerkungen: String?, + var status: String = "NEU" ) @Composable fun NennungsEingangScreen(onBack: () -> Unit) { DesktopThemeV2 { - var mails by remember { mutableStateOf(getMockMails()) } + var mails by remember { mutableStateOf>(emptyList()) } + var searchQuery by remember { mutableStateOf("") } + var selectedMail by remember { mutableStateOf(null) } 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)) { // Header 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) Text("Nennungs-Eingang (Online-Nennen)", style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.weight(1f)) + if (isRefreshing) CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) Button( onClick = { /* Refresh Logik */ }, enabled = !isRefreshing @@ -54,11 +95,22 @@ fun NennungsEingangScreen(onBack: () -> Unit) { } 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, 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 Card(modifier = Modifier.fillMaxWidth().weight(1f)) { Column { @@ -67,43 +119,50 @@ fun NennungsEingangScreen(onBack: () -> Unit) { Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant).padding(12.dp), 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("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("Pferd", Modifier.width(200.dp), 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() - if (mails.isEmpty()) { + if (filteredMails.isEmpty() && !isRefreshing) { 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 { LazyColumn(Modifier.fillMaxSize()) { - items(mails) { mail -> + items(filteredMails) { mail -> Row( Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), 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.turnierNr, Modifier.width(100.dp), fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - Text(mail.reiter, Modifier.width(200.dp), fontSize = 13.sp) + Text(mail.turnierNr, Modifier.width(80.dp), fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text("${mail.vorname} ${mail.nachname}", 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) - Row(Modifier.width(150.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { /* Übernahme Logik */ }, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), - modifier = Modifier.height(32.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary) - ) { - Icon(Icons.Default.Check, null, modifier = Modifier.size(14.dp)) - Spacer(Modifier.width(4.dp)) - Text("Übernehmen", fontSize = 11.sp) - } + Button( + onClick = { selectedMail = mail }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + modifier = Modifier.width(120.dp).height(32.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Text("Anzeigen", fontSize = 11.sp) } } HorizontalDivider(Modifier.padding(horizontal = 8.dp), thickness = 0.5.dp) @@ -116,8 +175,48 @@ fun NennungsEingangScreen(onBack: () -> Unit) { } } +@Composable +fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkProcessed: () -> Unit) { + AlertDialog( + 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", "Spirit", "1, 2, 5"), - OnlineNennungMail("2", "susi.sorglos@gmx.at", "meldestelle-26128@mo-code.at", "14.04.2026 15:12", "26128", "Susi Sorglos", "Flocke", "10, 11"), - OnlineNennungMail("3", "info@reitstall-hofer.at", "meldestelle-26129@mo-code.at", "14.04.2026 16:05", "26129", "Georg Hofer", "Black Beauty", "3, 4, 8") + 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") )