chore: consolidate redundant controllers in mail-service, improve backend stability, refine desktop UX, and enhance Vereinsverwaltung functionality

This commit is contained in:
2026-04-20 00:21:16 +02:00
parent bcabb86841
commit dfaa2e8545
14 changed files with 519 additions and 138 deletions
@@ -28,6 +28,7 @@ dependencies {
// Common service extras // Common service extras
implementation(libs.spring.boot.starter.validation) implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.mail) implementation(libs.spring.boot.starter.mail)
implementation(libs.spring.boot.starter.actuator)
// JSON + Web: ensure Spring Web (incl. HttpMessageConverters) is on the classpath // JSON + Web: ensure Spring Web (incl. HttpMessageConverters) is on the classpath
//implementation("org.springframework.boot:spring-boot-starter-web") //implementation("org.springframework.boot:spring-boot-starter-web")
implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.web)
@@ -1,34 +0,0 @@
@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)
}
@PostMapping
fun createNennung(@RequestBody nennung: NennungEntity) {
nennungRepository.save(nennung)
}
}
@@ -111,4 +111,49 @@ class MailController(
fun getAllNennungen(): List<NennungEntity> { fun getAllNennungen(): List<NennungEntity> {
return nennungRepository.findAll() return nennungRepository.findAll()
} }
@PutMapping("/nennungen/{id}/status")
fun updateStatus(
@PathVariable id: String,
@RequestBody newStatus: String
) {
nennungRepository.updateStatus(Uuid.parse(id), newStatus)
}
@PostMapping("/nennungen")
fun createNennung(@RequestBody nennung: NennungEntity) {
nennungRepository.save(nennung)
}
@PostMapping("/send-reply")
fun sendReply(
@RequestParam email: String,
@RequestParam turnierNr: String,
@RequestParam vorname: String,
@RequestParam nachname: String
) {
val message = SimpleMailMessage()
val dynamicFrom = try {
val (user, domain) = baseMailAddress.split("@")
"$user+$turnierNr@$domain"
} catch (_: Exception) {
baseMailAddress
}
message.from = dynamicFrom
message.setTo(email)
message.subject = "Bestätigung: Nennung für Turnier $turnierNr manuell übernommen"
message.text = """
Sehr geehrte(r) $vorname $nachname,
Ihre Online-Nennung für das Turnier $turnierNr wurde von uns manuell in das Turniersystem übernommen.
Viel Erfolg beim Turnier!
Mit freundlichen Grüßen,
Ihre Meldestelle
""".trimIndent()
mailSender.send(message)
logger.info("Antwort-Mail an $email gesendet.")
}
} }
@@ -0,0 +1,28 @@
# Journal: 19. April 2026 - Backend Stabilität & Desktop UX-Refinement
## 🏗️ Backend: Infrastruktur & Mail-Service
* **Mail-Service:** Konflikt beim Request-Mapping behoben. Der redundante `NennungController` wurde entfernt und seine Funktionalität (Status-Update, Erstellung) in den zentralen `MailController` integriert.
* **Health-Checks:** `spring-boot-starter-actuator` zum `entries-service` hinzugefügt, um die 404-Fehler in der Consul-Überwachung zu eliminieren.
* **Mail-Features:** Neuer Endpunkt `POST /send-reply` im `MailController` implementiert, um Bestätigungs-Mails an Nenner mit dynamischer Absenderadresse (Turnier-spezifisch) zu senden.
## 💻 Desktop-App: Navigation & UI
* **Veranstaltungs-Konfiguration:** White-Screen Fix durch Korrektur der Navigation im `DesktopMainLayout.kt`. Es wird nun korrekt auf den `VeranstaltungKonfigScreen` aus dem Feature-Modul verwiesen.
* **Device-Setup:** UX-Verbesserung durch Entfernung blockierender `onKeyEvent` Handler. Die Navigation zwischen Feldern mittels **Tab** und **Enter** funktioniert nun reibungslos über den Standard-Fokus-Flow.
* **Design-System:**
* Suchfeld-Höhe in `MsFilterBar.kt` auf `44.dp` erhöht, um abgeschnittenen Text bei kleinen Schriftarten zu verhindern.
* `MsMasterDetailLayout` im Vereins-Bereich um einen **Preview-Bereich** (Card-Ansicht) erweitert.
## 🚀 Neue Features
### Nennungs-Eingang
* **Antwort-Funktion:** Ein neuer Button "Antwort & Übernahme" im Detail-Dialog ermöglicht das direkte Versenden einer Bestätigungs-Mail an den Nenner.
* **Sortierung:** Die Liste wird nun standardmäßig mit neuen Nennungen (`NEU`) zuerst sortiert.
### Vereins-Verwaltung
* **Card-Preview:** Der obere Teil des Detail-Bereichs zeigt nun eine visuelle Vorschau des Vereins (Name, Status, Ort).
* **Logo-Support:** Das Domain-Modell und der Editor wurden um ein `logoUrl` Feld erweitert, um Vereinslogos (z.B. für nicht registrierte Vereine) zu hinterlegen.
## 🧹 Curator Hinweis
Alle gemeldeten Start-Fehler im Backend wurden behoben. Die Desktop-App ist nun voll navigierbar und bietet verbesserte Effizienz für die Meldestellen-Mitarbeiter.
@@ -0,0 +1,43 @@
# Journal-Eintrag: Vereins-Verwaltung Erweiterung (Logo & Adresse)
**Datum:** 20. April 2026
**Status:** In Umsetzung / Teilweise abgeschlossen
**Beteiligte Agenten:** 🏗️ [Lead Architect], 🎨 [Frontend Expert], 🧹 [Curator]
## 📝 Zusammenfassung
Die Vereins-Verwaltung wurde um detaillierte Adressdaten und ein verbessertes Logo-Management erweitert. Dies unterstützt die Professionalisierung der Stammdaten und verbessert die UX durch direkte Integration von Google Maps.
## 🛠️ Technische Änderungen
### 1. Domain-Modell (`Verein.kt`)
* Erweiterung um Felder: `strasse`, `hausnummer`, `bundesland` (Enum).
* Neues Feld `logoBase64` für die Offline-Speicherung von optimierten Vereinslogos.
* Einführung des Enums `Bundesland` mit den 9 österreichischen Bundesländern zur Sicherstellung der Datenqualität (ÖTO-konform).
### 2. ViewModel (`VereinViewModel.kt`)
* Erweiterung des `VereinUiState` um die neuen Adress- und Logo-Felder.
* Implementierung der Change-Handler für alle neuen Felder.
* Anpassung der `onSave`- und `onAddNew`-Methoden zur Berücksichtigung der erweiterten Datenstruktur.
### 3. UI-Anpassungen (`VereinScreens.kt`)
* **Card-Preview:**
* Anzeige der vollständigen Adresse (Straße, Hausnummer, PLZ, Ort, Bundesland).
* Integration eines "Maps"-Buttons, der die Adresse direkt in Google Maps öffnet (via `LocalUriHandler`).
* Vergrößertes Logo-Display (80dp) mit modernem Design.
* **Editor:**
* Logische Gruppierung der Adressfelder (Straße/Nr. in einer Zeile, PLZ/Ort/Bundesland in der nächsten).
* Einsatz des `MsEnumDropdown` für die Bundesland-Auswahl.
* Vorbereitung einer "Logo-Upload-Zone" mit visuellem Feedback für Drag-and-Drop / FilePicker.
## 🔍 Verifikation (Vorschau)
* [x] Domain-Modell kompiliert.
* [x] ViewModel-Logik deckt alle neuen Felder ab.
* [x] UI-Layout ist für High-Density Enterprise-UIs optimiert (44dp Standard).
## 📌 Nächste Schritte
* Implementierung der tatsächlichen Bild-Skalierung und Konvertierung (JVM-spezifisch) im `VereinViewModel`.
* Anbindung des nativen `JFileChooser` für den Logo-Import.
* Finalisierung der Drag-and-Drop Logik (`onExternalDrag`).
---
*Dokumentiert durch den Curator.*
@@ -51,7 +51,7 @@ fun MsFilterBar(
onValueChange = onSearchQueryChange, onValueChange = onSearchQueryChange,
modifier = Modifier modifier = Modifier
.width(300.dp) .width(300.dp)
.height(40.dp), // Fixe Höhe für High-Density .height(44.dp), // Erhöht von 40.dp auf 44.dp, damit Text nicht abgeschnitten wird
placeholder = { Text(searchPlaceholder, style = MaterialTheme.typography.bodySmall) }, placeholder = { Text(searchPlaceholder, style = MaterialTheme.typography.bodySmall) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
trailingIcon = if (searchQuery.isNotEmpty()) { trailingIcon = if (searchQuery.isNotEmpty()) {
@@ -63,14 +63,7 @@ actual fun DeviceInitializationConfig(
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
modifier = Modifier.focusRequester(deviceNameFocus).onKeyEvent { modifier = Modifier.focusRequester(deviceNameFocus)
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
focusManager.moveFocus(FocusDirection.Next)
true
} else {
false
}
}
) )
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
@@ -95,18 +88,7 @@ actual fun DeviceInitializationConfig(
} }
} }
), ),
modifier = Modifier.focusRequester(sharedKeyFocus).onKeyEvent { modifier = Modifier.focusRequester(sharedKeyFocus),
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
if (settings.networkRole == NetworkRole.MASTER) {
focusManager.moveFocus(FocusDirection.Next)
} else if (DeviceInitializationValidator.canContinue(settings)) {
viewModel.completeInitialization()
}
true
} else {
false
}
},
trailingIcon = { trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) { IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon( Icon(
@@ -123,17 +105,8 @@ actual fun DeviceInitializationConfig(
onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } },
label = { Text("Backup-Verzeichnis (Pfad)") }, label = { Text("Backup-Verzeichnis (Pfad)") },
placeholder = { Text("/pfad/zu/den/backups") }, placeholder = { Text("/pfad/zu/den/backups") },
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus).onKeyEvent { modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus),
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
selectBackupPath(settings.backupPath) { selectedPath ->
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
}
true
} else {
false
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) } onNext = { focusManager.moveFocus(FocusDirection.Next) }
), ),
@@ -50,10 +50,26 @@ class NennungRemoteRepository(private val client: HttpClient) {
} }
} }
suspend fun sendeAntwort(email: String, turnierNr: String, vorname: String, nachname: String): Result<Unit> {
return try {
client.post("$mailServiceUrl/api/mail/send-reply") {
parameter("email", email)
parameter("turnierNr", turnierNr)
parameter("vorname", vorname)
parameter("nachname", nachname)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun markiereAlsGelesen(id: String): Result<Unit> { suspend fun markiereAlsGelesen(id: String): Result<Unit> {
return try { return try {
// Endpunkt müsste im Backend noch implementiert werden, falls gewünscht. client.put("$mailServiceUrl/api/mail/nennungen/$id/status") {
// Für jetzt simuliert: contentType(ContentType.Application.Json)
setBody("GELESEN")
}
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
@@ -12,10 +12,27 @@ data class Verein(
val oepsNr: String? = null, val oepsNr: String? = null,
val ort: String? = null, val ort: String? = null,
val plz: String? = null, val plz: String? = null,
val strasse: String? = null,
val hausnummer: String? = null,
val bundesland: String? = null,
val land: String = "AUT", val land: String = "AUT",
val status: VereinStatus = VereinStatus.AKTIV val status: VereinStatus = VereinStatus.AKTIV,
val logoUrl: String? = null,
val logoBase64: String? = null
) )
enum class Bundesland(val label: String) {
BURGENLAND("Burgenland"),
KAERNTEN("Kärnten"),
NIEDEROESTERREICH("Niederösterreich"),
OBEROESTERREICH("Oberösterreich"),
SALZBURG("Salzburg"),
STEIERMARK("Steiermark"),
TIROL("Tirol"),
VORARLBERG("Vorarlberg"),
WIEN("Wien")
}
enum class VereinStatus(val label: String, val color: Color) { enum class VereinStatus(val label: String, val color: Color) {
AKTIV("Aktiv", Color(0xFF2E7D32)), AKTIV("Aktiv", Color(0xFF2E7D32)),
RUHEND("Ruhend", Color(0xFFE65100)), RUHEND("Ruhend", Color(0xFFE65100)),
@@ -1,13 +1,28 @@
package at.mocode.frontend.features.verein.presentation package at.mocode.frontend.features.verein.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Text import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Business
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Map
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.* import at.mocode.frontend.core.designsystem.components.*
import at.mocode.frontend.core.designsystem.models.PlaceholderContent import at.mocode.frontend.core.designsystem.models.PlaceholderContent
import at.mocode.frontend.features.verein.domain.Bundesland
import at.mocode.frontend.features.verein.domain.Verein import at.mocode.frontend.features.verein.domain.Verein
import at.mocode.frontend.features.verein.domain.VereinStatus import at.mocode.frontend.features.verein.domain.VereinStatus
@@ -27,28 +42,153 @@ fun VereinScreen(
) )
}, },
detail = { detail = {
if (uiState.isEditing) { Column(Modifier.fillMaxSize()) {
VereinEditorContent( if (uiState.selectedVerein != null || uiState.isEditing) {
uiState = uiState, // --- Preview Bereich ---
onNameChange = viewModel::onEditNameChange, VereinCardPreview(
onLangnameChange = viewModel::onEditLangnameChange, name = if (uiState.isEditing) uiState.editName else uiState.selectedVerein?.name ?: "",
onOepsNrChange = viewModel::onEditOepsNrChange, langname = if (uiState.isEditing) uiState.editLangname else uiState.selectedVerein?.langname,
onOrtChange = viewModel::onEditOrtChange, ort = if (uiState.isEditing) uiState.editOrt else uiState.selectedVerein?.ort,
onPlzChange = viewModel::onEditPlzChange, plz = if (uiState.isEditing) uiState.editPlz else uiState.selectedVerein?.plz,
onStatusChange = viewModel::onEditStatusChange, strasse = if (uiState.isEditing) uiState.editStrasse else uiState.selectedVerein?.strasse,
onSave = viewModel::onSave, hausnummer = if (uiState.isEditing) uiState.editHausnummer else uiState.selectedVerein?.hausnummer,
onCancel = viewModel::onCancel bundesland = if (uiState.isEditing) uiState.editBundesland else uiState.selectedVerein?.bundesland,
) logoUrl = if (uiState.isEditing) uiState.editLogoUrl else uiState.selectedVerein?.logoUrl,
} else { status = if (uiState.isEditing) uiState.editStatus else uiState.selectedVerein?.status ?: VereinStatus.AKTIV
PlaceholderContent( )
title = "Kein Verein ausgewählt",
subtitle = "Wählen Sie einen Verein aus der Liste aus oder legen Sie einen neuen an." Spacer(Modifier.height(16.dp))
) HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(16.dp))
if (uiState.isEditing) {
VereinEditorContent(
uiState = uiState,
onNameChange = viewModel::onEditNameChange,
onLangnameChange = viewModel::onEditLangnameChange,
onOepsNrChange = viewModel::onEditOepsNrChange,
onOrtChange = viewModel::onEditOrtChange,
onPlzChange = viewModel::onEditPlzChange,
onStrasseChange = viewModel::onEditStrasseChange,
onHausnummerChange = viewModel::onEditHausnummerChange,
onBundeslandChange = viewModel::onEditBundeslandChange,
onStatusChange = viewModel::onEditStatusChange,
onLogoUrlChange = viewModel::onEditLogoUrlChange,
onSave = viewModel::onSave,
onCancel = viewModel::onCancel
)
}
} else {
PlaceholderContent(
title = "Kein Verein ausgewählt",
subtitle = "Wählen Sie einen Verein aus der Liste aus oder legen Sie einen neuen an."
)
}
} }
} }
) )
} }
@Composable
private fun VereinCardPreview(
name: String,
langname: String?,
ort: String?,
plz: String?,
strasse: String?,
hausnummer: String?,
bundesland: String?,
logoUrl: String?,
status: VereinStatus
) {
val uriHandler = LocalUriHandler.current
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Logo Placeholder / Image
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
.border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), CircleShape),
contentAlignment = Alignment.Center
) {
if (!logoUrl.isNullOrBlank()) {
Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary)
} else {
Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary)
}
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = name.ifBlank { "Vereinsname" },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
MsStatusBadge(
text = status.label,
containerColor = status.color.copy(alpha = 0.1f),
contentColor = status.color
)
}
if (!langname.isNullOrBlank()) {
Text(langname, style = MaterialTheme.typography.bodyMedium, color = Color.Gray)
}
val adresse = buildString {
if (!strasse.isNullOrBlank()) {
append(strasse)
if (!hausnummer.isNullOrBlank()) append(" $hausnummer")
append(", ")
}
if (!plz.isNullOrBlank()) append("$plz ")
if (!ort.isNullOrBlank()) append(ort)
if (!bundesland.isNullOrBlank()) {
if (isNotEmpty() && !endsWith(", ")) append(", ")
append(bundesland)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(top = 4.dp)
) {
Text(
text = if (adresse.isNotBlank()) "📍 $adresse" else "Keine Adresse angegeben",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
if (adresse.isNotBlank()) {
MsButton(
text = "📍 Maps",
onClick = {
val query = adresse.replace(" ", "+")
uriHandler.openUri("https://www.google.com/maps/search/?api=1&query=$query")
},
variant = ButtonVariant.TEXT,
size = ButtonSize.SMALL
)
}
}
}
}
}
}
@Composable @Composable
private fun VereinListContent( private fun VereinListContent(
uiState: VereinUiState, uiState: VereinUiState,
@@ -116,11 +256,15 @@ private fun VereinEditorContent(
onOepsNrChange: (String) -> Unit, onOepsNrChange: (String) -> Unit,
onOrtChange: (String) -> Unit, onOrtChange: (String) -> Unit,
onPlzChange: (String) -> Unit, onPlzChange: (String) -> Unit,
onStrasseChange: (String) -> Unit,
onHausnummerChange: (String) -> Unit,
onBundeslandChange: (String) -> Unit,
onStatusChange: (VereinStatus) -> Unit, onStatusChange: (VereinStatus) -> Unit,
onLogoUrlChange: (String) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onCancel: () -> Unit onCancel: () -> Unit
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
MsActionToolbar( MsActionToolbar(
title = if (uiState.selectedVerein == null) "Neuer Verein" else "Verein Details", title = if (uiState.selectedVerein == null) "Neuer Verein" else "Verein Details",
onSave = onSave, onSave = onSave,
@@ -129,21 +273,47 @@ private fun VereinEditorContent(
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
MsTextField( Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
value = uiState.editName, Column(modifier = Modifier.weight(1f)) {
onValueChange = onNameChange, MsTextField(
label = "Name (Kurz)", value = uiState.editName,
modifier = Modifier.fillMaxWidth() onValueChange = onNameChange,
) label = "Name (Kurz)",
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
MsTextField( MsTextField(
value = uiState.editLangname, value = uiState.editLangname,
onValueChange = onLangnameChange, onValueChange = onLangnameChange,
label = "Vollständiger Name", label = "Vollständiger Name",
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
}
// Logo Upload Sektion
Column(
modifier = Modifier
.width(200.dp)
.height(120.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Image, null, tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
Text("Logo hierher ziehen", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Spacer(Modifier.height(4.dp))
MsButton(
text = "Wählen",
onClick = { /* FilePicker Call via JFileChooser (JVM only) */ },
variant = ButtonVariant.SECONDARY,
size = ButtonSize.SMALL
)
}
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -166,19 +336,49 @@ private fun VereinEditorContent(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("Adresse", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editStrasse,
onValueChange = onStrasseChange,
label = "Straße",
modifier = Modifier.weight(0.7f)
)
MsTextField(
value = uiState.editHausnummer,
onValueChange = onHausnummerChange,
label = "Nr.",
modifier = Modifier.weight(0.3f)
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField( MsTextField(
value = uiState.editPlz, value = uiState.editPlz,
onValueChange = onPlzChange, onValueChange = onPlzChange,
label = "PLZ", label = "PLZ",
modifier = Modifier.weight(0.3f) modifier = Modifier.weight(0.2f)
) )
MsTextField( MsTextField(
value = uiState.editOrt, value = uiState.editOrt,
onValueChange = onOrtChange, onValueChange = onOrtChange,
label = "Ort", label = "Ort",
modifier = Modifier.weight(0.7f) modifier = Modifier.weight(0.4f)
)
MsEnumDropdown(
label = "Bundesland",
options = Bundesland.entries.toTypedArray(),
selectedOption = Bundesland.entries.find { it.label == uiState.editBundesland },
onOptionSelected = { onBundeslandChange(it.label) },
optionLabel = { it.label },
modifier = Modifier.weight(0.4f)
) )
} }
Spacer(Modifier.height(32.dp))
} }
} }
@@ -26,7 +26,12 @@ data class VereinUiState(
val editOepsNr: String = "", val editOepsNr: String = "",
val editOrt: String = "", val editOrt: String = "",
val editPlz: String = "", val editPlz: String = "",
val editStatus: VereinStatus = VereinStatus.AKTIV val editStrasse: String = "",
val editHausnummer: String = "",
val editBundesland: String = "",
val editStatus: VereinStatus = VereinStatus.AKTIV,
val editLogoUrl: String = "",
val editLogoBase64: String = ""
) )
/** /**
@@ -99,10 +104,35 @@ open class VereinViewModel(
editOepsNr = verein.oepsNr ?: "", editOepsNr = verein.oepsNr ?: "",
editOrt = verein.ort ?: "", editOrt = verein.ort ?: "",
editPlz = verein.plz ?: "", editPlz = verein.plz ?: "",
editStatus = verein.status editStrasse = verein.strasse ?: "",
editHausnummer = verein.hausnummer ?: "",
editBundesland = verein.bundesland ?: "",
editStatus = verein.status,
editLogoUrl = verein.logoUrl ?: "",
editLogoBase64 = verein.logoBase64 ?: ""
) )
} }
fun onEditStrasseChange(value: String) {
uiState = uiState.copy(editStrasse = value)
}
fun onEditHausnummerChange(value: String) {
uiState = uiState.copy(editHausnummer = value)
}
fun onEditBundeslandChange(value: String) {
uiState = uiState.copy(editBundesland = value)
}
fun onEditLogoBase64Change(value: String) {
uiState = uiState.copy(editLogoBase64 = value)
}
fun onEditLogoUrlChange(value: String) {
uiState = uiState.copy(editLogoUrl = value)
}
fun onEditNameChange(value: String) { fun onEditNameChange(value: String) {
uiState = uiState.copy(editName = value) uiState = uiState.copy(editName = value)
} }
@@ -138,7 +168,12 @@ open class VereinViewModel(
oepsNr = uiState.editOepsNr, oepsNr = uiState.editOepsNr,
ort = uiState.editOrt, ort = uiState.editOrt,
plz = uiState.editPlz, plz = uiState.editPlz,
status = uiState.editStatus strasse = uiState.editStrasse,
hausnummer = uiState.editHausnummer,
bundesland = uiState.editBundesland,
status = uiState.editStatus,
logoUrl = uiState.editLogoUrl.ifBlank { null },
logoBase64 = uiState.editLogoBase64.ifBlank { null }
) )
viewModelScope.launch { viewModelScope.launch {
@@ -169,7 +204,12 @@ open class VereinViewModel(
editOepsNr = "", editOepsNr = "",
editOrt = "", editOrt = "",
editPlz = "", editPlz = "",
editStatus = VereinStatus.AKTIV editStrasse = "",
editHausnummer = "",
editBundesland = "",
editStatus = VereinStatus.AKTIV,
editLogoUrl = "",
editLogoBase64 = ""
) )
} }
} }
@@ -1,6 +1,12 @@
{ {
"deviceName": "Meldestelle", "deviceName": "Meldestelle\n",
"sharedKey": "Password", "sharedKey": "Password",
"backupPath": "/mocode/meldestelle/docs/temp", "backupPath": "/mocode/meldestelle/docs/temp",
"networkRole": "MASTER" "networkRole": "MASTER",
"expectedClients": [
{
"name": "Richter-Turm",
"role": "RICHTER"
}
]
} }
@@ -16,20 +16,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
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 at.mocode.frontend.shell.desktop.data.Store
import at.mocode.frontend.shell.desktop.data.Turnier
import at.mocode.frontend.shell.desktop.data.TurnierStore
import at.mocode.frontend.shell.desktop.screens.management.FunktionaerVerwaltungScreen
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterAuswahl
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterDetail
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterVerwaltungScreen
import at.mocode.frontend.shell.desktop.screens.nennung.NennungsEingangScreen
import at.mocode.frontend.shell.desktop.screens.profile.FunktionaerProfil
import at.mocode.frontend.shell.desktop.screens.veranstaltung.VeranstaltungVerwaltung
import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungKonfig
import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.TurnierWizard
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.VeranstalterAnlegenWizard
import at.mocode.frontend.core.auth.data.local.AuthTokenManager import at.mocode.frontend.core.auth.data.local.AuthTokenManager
import at.mocode.frontend.core.designsystem.theme.AppColors import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens import at.mocode.frontend.core.designsystem.theme.Dimens
@@ -47,20 +33,34 @@ import at.mocode.frontend.features.nennung.presentation.NennungManagementScreen
import at.mocode.frontend.features.nennung.presentation.NennungViewModel import at.mocode.frontend.features.nennung.presentation.NennungViewModel
import at.mocode.frontend.features.pferde.presentation.PferdeScreen import at.mocode.frontend.features.pferde.presentation.PferdeScreen
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
import at.mocode.frontend.features.ping.presentation.PingScreen
import at.mocode.frontend.features.ping.presentation.PingViewModel
import at.mocode.frontend.features.profile.presentation.ProfileScreen import at.mocode.frontend.features.profile.presentation.ProfileScreen
import at.mocode.frontend.features.profile.presentation.ProfileViewModel import at.mocode.frontend.features.profile.presentation.ProfileViewModel
import at.mocode.frontend.features.reiter.presentation.ReiterScreen import at.mocode.frontend.features.reiter.presentation.ReiterScreen
import at.mocode.frontend.features.reiter.presentation.ReiterViewModel import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
import at.mocode.frontend.features.verein.presentation.VereinScreen
import at.mocode.frontend.features.verein.presentation.VereinViewModel
import at.mocode.frontend.features.ping.presentation.PingScreen
import at.mocode.frontend.features.ping.presentation.PingViewModel
import at.mocode.frontend.features.turnier.presentation.SeriesScreen import at.mocode.frontend.features.turnier.presentation.SeriesScreen
import at.mocode.frontend.features.turnier.presentation.TurnierDetailScreen import at.mocode.frontend.features.turnier.presentation.TurnierDetailScreen
import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfigScreen
import at.mocode.frontend.features.verein.presentation.VereinScreen
import at.mocode.frontend.features.verein.presentation.VereinViewModel
import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScreen
import at.mocode.frontend.shell.desktop.data.Store
import at.mocode.frontend.shell.desktop.data.Turnier
import at.mocode.frontend.shell.desktop.data.TurnierStore
import at.mocode.frontend.shell.desktop.screens.management.FunktionaerVerwaltungScreen
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterAuswahl
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterDetail
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterVerwaltungScreen
import at.mocode.frontend.shell.desktop.screens.nennung.NennungsEingangScreen
import at.mocode.frontend.shell.desktop.screens.profile.FunktionaerProfil
import at.mocode.frontend.shell.desktop.screens.veranstaltung.VeranstaltungVerwaltung
import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.TurnierWizard
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.VeranstalterAnlegenWizard
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScreen
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -671,12 +671,24 @@ private fun DesktopContentArea(
is AppScreen.VeranstaltungKonfig -> { is AppScreen.VeranstaltungKonfig -> {
val vId = currentScreen.veranstalterId val vId = currentScreen.veranstalterId
// Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard VeranstaltungKonfigScreen(
VeranstaltungKonfig(
veranstalterId = vId, veranstalterId = vId,
onBack = onBack, onAbbrechen = onBack,
onSaved = { evtId: Long, finalVId: Long -> onNavigate(AppScreen.VeranstaltungProfil(finalVId, evtId)) }, onSpeichern = { titel, datumVon, datumBis ->
onVeranstalterCreated = { newVId: Long -> onNavigate(AppScreen.VeranstalterDetail(newVId)) } // In-Memory Store Simulation
val allEvents = Store.allEvents()
val newId = (allEvents.maxOfOrNull { it.id } ?: 0L) + 1L
val newEvent = at.mocode.frontend.shell.desktop.data.Veranstaltung(
id = newId,
veranstalterId = vId,
titel = titel,
datumVon = datumVon,
datumBis = datumBis,
status = "NEU"
)
Store.addEventFirst(vId, newEvent)
onNavigate(AppScreen.VeranstaltungProfil(vId, newId))
}
) )
} }
@@ -83,13 +83,15 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
} }
val filteredMails = remember(mails, searchQuery) { val filteredMails = remember(mails, searchQuery) {
if (searchQuery.isBlank()) mails val base = if (searchQuery.isBlank()) mails
else mails.filter { else mails.filter {
it.vorname.contains(searchQuery, ignoreCase = true) || it.vorname.contains(searchQuery, ignoreCase = true) ||
it.nachname.contains(searchQuery, ignoreCase = true) || it.nachname.contains(searchQuery, ignoreCase = true) ||
it.pferd.contains(searchQuery, ignoreCase = true) || it.pferd.contains(searchQuery, ignoreCase = true) ||
it.turnierNr.contains(searchQuery, ignoreCase = true) it.turnierNr.contains(searchQuery, ignoreCase = true)
} }
// Standard-Sortierung: Neueste zuerst (Status NEU oben, dann nach TurnierNr)
base.sortedWith(compareBy({ it.status != "NEU" }, { it.turnierNr }, { it.datum }))
} }
// Initiales Laden // Initiales Laden
@@ -108,6 +110,21 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
mails = updated mails = updated
selectedMail = null selectedMail = null
} }
},
onSendReply = {
scope.launch {
repository.sendeAntwort(
email = selectedMail!!.sender,
turnierNr = selectedMail!!.turnierNr,
vorname = selectedMail!!.vorname,
nachname = selectedMail!!.nachname
)
// Nach Antwort automatisch als gelesen markieren
repository.markiereAlsGelesen(selectedMail!!.id)
val updated = mails.map { if (it.id == selectedMail!!.id) it.copy(status = "GELESEN") else it }
mails = updated
selectedMail = null
}
} }
) )
} }
@@ -212,7 +229,12 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
} }
@Composable @Composable
fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkProcessed: () -> Unit) { fun NennungDetailDialog(
mail: OnlineNennungMail,
onDismiss: () -> Unit,
onMarkProcessed: () -> Unit,
onSendReply: () -> Unit
) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Details zur Online-EntryManagement") }, title = { Text("Details zur Online-EntryManagement") },
@@ -235,7 +257,19 @@ fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkPr
} }
}, },
confirmButton = { confirmButton = {
Button(onClick = onMarkProcessed) { Text("Als gelesen markieren") } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (mail.status == "NEU") {
Button(
onClick = onSendReply,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32))
) {
Icon(Icons.Default.Email, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Antwort & Übernahme")
}
}
Button(onClick = onMarkProcessed) { Text("Als gelesen markieren") }
}
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { Text("Schließen") } TextButton(onClick = onDismiss) { Text("Schließen") }