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

This commit is contained in:
Stefan Mogeritsch 2026-04-20 00:21:16 +02:00
parent bcabb86841
commit dfaa2e8545
14 changed files with 519 additions and 138 deletions

View File

@ -28,6 +28,7 @@ dependencies {
// Common service extras
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.mail)
implementation(libs.spring.boot.starter.actuator)
// JSON + Web: ensure Spring Web (incl. HttpMessageConverters) is on the classpath
//implementation("org.springframework.boot:spring-boot-starter-web")
implementation(libs.spring.boot.starter.web)

View File

@ -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)
}
}

View File

@ -111,4 +111,49 @@ class MailController(
fun getAllNennungen(): List<NennungEntity> {
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.")
}
}

View File

@ -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.

View File

@ -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.*

View File

@ -51,7 +51,7 @@ fun MsFilterBar(
onValueChange = onSearchQueryChange,
modifier = Modifier
.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) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
trailingIcon = if (searchQuery.isNotEmpty()) {

View File

@ -63,14 +63,7 @@ actual fun DeviceInitializationConfig(
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
modifier = Modifier.focusRequester(deviceNameFocus).onKeyEvent {
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
focusManager.moveFocus(FocusDirection.Next)
true
} else {
false
}
}
modifier = Modifier.focusRequester(deviceNameFocus)
)
var passwordVisible by remember { mutableStateOf(false) }
@ -95,18 +88,7 @@ actual fun DeviceInitializationConfig(
}
}
),
modifier = Modifier.focusRequester(sharedKeyFocus).onKeyEvent {
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
}
},
modifier = Modifier.focusRequester(sharedKeyFocus),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
@ -123,17 +105,8 @@ actual fun DeviceInitializationConfig(
onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } },
label = { Text("Backup-Verzeichnis (Pfad)") },
placeholder = { Text("/pfad/zu/den/backups") },
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus).onKeyEvent {
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
selectBackupPath(settings.backupPath) { selectedPath ->
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
}
true
} else {
false
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) }
),

View File

@ -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> {
return try {
// Endpunkt müsste im Backend noch implementiert werden, falls gewünscht.
// Für jetzt simuliert:
client.put("$mailServiceUrl/api/mail/nennungen/$id/status") {
contentType(ContentType.Application.Json)
setBody("GELESEN")
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)

View File

@ -12,10 +12,27 @@ data class Verein(
val oepsNr: String? = null,
val ort: String? = null,
val plz: String? = null,
val strasse: String? = null,
val hausnummer: String? = null,
val bundesland: String? = null,
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) {
AKTIV("Aktiv", Color(0xFF2E7D32)),
RUHEND("Ruhend", Color(0xFFE65100)),

View File

@ -1,13 +1,28 @@
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
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.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 at.mocode.frontend.core.designsystem.components.*
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.VereinStatus
@ -27,28 +42,153 @@ fun VereinScreen(
)
},
detail = {
if (uiState.isEditing) {
VereinEditorContent(
uiState = uiState,
onNameChange = viewModel::onEditNameChange,
onLangnameChange = viewModel::onEditLangnameChange,
onOepsNrChange = viewModel::onEditOepsNrChange,
onOrtChange = viewModel::onEditOrtChange,
onPlzChange = viewModel::onEditPlzChange,
onStatusChange = viewModel::onEditStatusChange,
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."
)
Column(Modifier.fillMaxSize()) {
if (uiState.selectedVerein != null || uiState.isEditing) {
// --- Preview Bereich ---
VereinCardPreview(
name = if (uiState.isEditing) uiState.editName else uiState.selectedVerein?.name ?: "",
langname = if (uiState.isEditing) uiState.editLangname else uiState.selectedVerein?.langname,
ort = if (uiState.isEditing) uiState.editOrt else uiState.selectedVerein?.ort,
plz = if (uiState.isEditing) uiState.editPlz else uiState.selectedVerein?.plz,
strasse = if (uiState.isEditing) uiState.editStrasse else uiState.selectedVerein?.strasse,
hausnummer = if (uiState.isEditing) uiState.editHausnummer else uiState.selectedVerein?.hausnummer,
bundesland = if (uiState.isEditing) uiState.editBundesland else uiState.selectedVerein?.bundesland,
logoUrl = if (uiState.isEditing) uiState.editLogoUrl else uiState.selectedVerein?.logoUrl,
status = if (uiState.isEditing) uiState.editStatus else uiState.selectedVerein?.status ?: VereinStatus.AKTIV
)
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
private fun VereinListContent(
uiState: VereinUiState,
@ -116,11 +256,15 @@ private fun VereinEditorContent(
onOepsNrChange: (String) -> Unit,
onOrtChange: (String) -> Unit,
onPlzChange: (String) -> Unit,
onStrasseChange: (String) -> Unit,
onHausnummerChange: (String) -> Unit,
onBundeslandChange: (String) -> Unit,
onStatusChange: (VereinStatus) -> Unit,
onLogoUrlChange: (String) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
MsActionToolbar(
title = if (uiState.selectedVerein == null) "Neuer Verein" else "Verein Details",
onSave = onSave,
@ -129,21 +273,47 @@ private fun VereinEditorContent(
Spacer(Modifier.height(24.dp))
MsTextField(
value = uiState.editName,
onValueChange = onNameChange,
label = "Name (Kurz)",
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
MsTextField(
value = uiState.editName,
onValueChange = onNameChange,
label = "Name (Kurz)",
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(16.dp))
MsTextField(
value = uiState.editLangname,
onValueChange = onLangnameChange,
label = "Vollständiger Name",
modifier = Modifier.fillMaxWidth()
)
MsTextField(
value = uiState.editLangname,
onValueChange = onLangnameChange,
label = "Vollständiger Name",
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))
@ -166,19 +336,49 @@ private fun VereinEditorContent(
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)) {
MsTextField(
value = uiState.editPlz,
onValueChange = onPlzChange,
label = "PLZ",
modifier = Modifier.weight(0.3f)
modifier = Modifier.weight(0.2f)
)
MsTextField(
value = uiState.editOrt,
onValueChange = onOrtChange,
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))
}
}

View File

@ -26,7 +26,12 @@ data class VereinUiState(
val editOepsNr: String = "",
val editOrt: 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 ?: "",
editOrt = verein.ort ?: "",
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) {
uiState = uiState.copy(editName = value)
}
@ -138,7 +168,12 @@ open class VereinViewModel(
oepsNr = uiState.editOepsNr,
ort = uiState.editOrt,
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 {
@ -169,7 +204,12 @@ open class VereinViewModel(
editOepsNr = "",
editOrt = "",
editPlz = "",
editStatus = VereinStatus.AKTIV
editStrasse = "",
editHausnummer = "",
editBundesland = "",
editStatus = VereinStatus.AKTIV,
editLogoUrl = "",
editLogoBase64 = ""
)
}
}

View File

@ -1,6 +1,12 @@
{
"deviceName": "Meldestelle",
"deviceName": "Meldestelle\n",
"sharedKey": "Password",
"backupPath": "/mocode/meldestelle/docs/temp",
"networkRole": "MASTER"
"networkRole": "MASTER",
"expectedClients": [
{
"name": "Richter-Turm",
"role": "RICHTER"
}
]
}

View File

@ -16,20 +16,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.designsystem.theme.AppColors
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.pferde.presentation.PferdeScreen
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.ProfileViewModel
import at.mocode.frontend.features.reiter.presentation.ReiterScreen
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.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.VeranstaltungDetailScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScreen
import kotlinx.coroutines.delay
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@ -671,12 +671,24 @@ private fun DesktopContentArea(
is AppScreen.VeranstaltungKonfig -> {
val vId = currentScreen.veranstalterId
// Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard
VeranstaltungKonfig(
VeranstaltungKonfigScreen(
veranstalterId = vId,
onBack = onBack,
onSaved = { evtId: Long, finalVId: Long -> onNavigate(AppScreen.VeranstaltungProfil(finalVId, evtId)) },
onVeranstalterCreated = { newVId: Long -> onNavigate(AppScreen.VeranstalterDetail(newVId)) }
onAbbrechen = onBack,
onSpeichern = { titel, datumVon, datumBis ->
// 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))
}
)
}

View File

@ -83,13 +83,15 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
}
val filteredMails = remember(mails, searchQuery) {
if (searchQuery.isBlank()) mails
val base = 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)
}
// Standard-Sortierung: Neueste zuerst (Status NEU oben, dann nach TurnierNr)
base.sortedWith(compareBy({ it.status != "NEU" }, { it.turnierNr }, { it.datum }))
}
// Initiales Laden
@ -108,6 +110,21 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
mails = updated
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
fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkProcessed: () -> Unit) {
fun NennungDetailDialog(
mail: OnlineNennungMail,
onDismiss: () -> Unit,
onMarkProcessed: () -> Unit,
onSendReply: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Details zur Online-EntryManagement") },
@ -235,7 +257,19 @@ fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkPr
}
},
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 = {
TextButton(onClick = onDismiss) { Text("Schließen") }