From dfaa2e8545c8f97d3f53123ae08b5c2f3babbe93 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Mon, 20 Apr 2026 00:21:16 +0200 Subject: [PATCH] chore: consolidate redundant controllers in `mail-service`, improve backend stability, refine desktop UX, and enhance Vereinsverwaltung functionality --- .../entries/entries-service/build.gradle.kts | 1 + .../mocode/mail/service/NennungController.kt | 34 --- .../mocode/mail/service/api/MailController.kt | 45 +++ ...-04-19_Backend_Stability_and_Desktop_UX.md | 28 ++ ...6-04-20_Vereins_Verwaltung_Logo_Adresse.md | 43 +++ .../designsystem/components/MsFilterBar.kt | 2 +- .../DeviceInitializationConfig.jvm.kt | 35 +-- .../nennung/domain/NennungRemoteRepository.kt | 20 +- .../frontend/features/verein/domain/Verein.kt | 19 +- .../verein/presentation/VereinScreens.kt | 272 +++++++++++++++--- .../verein/presentation/VereinViewModel.kt | 48 +++- .../shells/meldestelle-desktop/settings.json | 10 +- .../screens/layout/DesktopMainLayout.kt | 60 ++-- .../screens/nennung/NennungsEingangScreen.kt | 40 ++- 14 files changed, 519 insertions(+), 138 deletions(-) delete mode 100644 backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/NennungController.kt create mode 100644 docs/99_Journal/2026-04-19_Backend_Stability_and_Desktop_UX.md create mode 100644 docs/99_Journal/2026-04-20_Vereins_Verwaltung_Logo_Adresse.md diff --git a/backend/services/entries/entries-service/build.gradle.kts b/backend/services/entries/entries-service/build.gradle.kts index a71c7119..200c76a6 100644 --- a/backend/services/entries/entries-service/build.gradle.kts +++ b/backend/services/entries/entries-service/build.gradle.kts @@ -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) 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 deleted file mode 100644 index e7aa3b0d..00000000 --- a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/NennungController.kt +++ /dev/null @@ -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 { - 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) - } -} diff --git a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/api/MailController.kt b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/api/MailController.kt index bd3faf8e..ecaba5e3 100644 --- a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/api/MailController.kt +++ b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/api/MailController.kt @@ -111,4 +111,49 @@ class MailController( fun getAllNennungen(): List { 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.") + } } diff --git a/docs/99_Journal/2026-04-19_Backend_Stability_and_Desktop_UX.md b/docs/99_Journal/2026-04-19_Backend_Stability_and_Desktop_UX.md new file mode 100644 index 00000000..f9fb4d2a --- /dev/null +++ b/docs/99_Journal/2026-04-19_Backend_Stability_and_Desktop_UX.md @@ -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. diff --git a/docs/99_Journal/2026-04-20_Vereins_Verwaltung_Logo_Adresse.md b/docs/99_Journal/2026-04-20_Vereins_Verwaltung_Logo_Adresse.md new file mode 100644 index 00000000..81fc3023 --- /dev/null +++ b/docs/99_Journal/2026-04-20_Vereins_Verwaltung_Logo_Adresse.md @@ -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.* diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilterBar.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilterBar.kt index 13db5a0b..6556c432 100644 --- a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilterBar.kt +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilterBar.kt @@ -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()) { diff --git a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt index 3ec80ae2..5bfdc714 100644 --- a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt +++ b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt @@ -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) } ), diff --git a/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/domain/NennungRemoteRepository.kt b/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/domain/NennungRemoteRepository.kt index 14b8bb98..201c4d58 100644 --- a/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/domain/NennungRemoteRepository.kt +++ b/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/domain/NennungRemoteRepository.kt @@ -50,10 +50,26 @@ class NennungRemoteRepository(private val client: HttpClient) { } } + suspend fun sendeAntwort(email: String, turnierNr: String, vorname: String, nachname: String): Result { + 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 { 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) diff --git a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/domain/Verein.kt b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/domain/Verein.kt index fcdeaaca..c28f41fb 100644 --- a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/domain/Verein.kt +++ b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/domain/Verein.kt @@ -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)), diff --git a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinScreens.kt b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinScreens.kt index 46300547..bcd7c029 100644 --- a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinScreens.kt +++ b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinScreens.kt @@ -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)) } } diff --git a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinViewModel.kt b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinViewModel.kt index 460c8a16..ce1aee2f 100644 --- a/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinViewModel.kt +++ b/frontend/features/verein-feature/src/commonMain/kotlin/at/mocode/frontend/features/verein/presentation/VereinViewModel.kt @@ -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 = "" ) } } diff --git a/frontend/shells/meldestelle-desktop/settings.json b/frontend/shells/meldestelle-desktop/settings.json index f2ec04c2..462cdf40 100644 --- a/frontend/shells/meldestelle-desktop/settings.json +++ b/frontend/shells/meldestelle-desktop/settings.json @@ -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" + } + ] } \ No newline at end of file diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt index bbba7595..bca15fac 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt @@ -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)) + } ) } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/nennung/NennungsEingangScreen.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/nennung/NennungsEingangScreen.kt index 054c26e0..2961a006 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/nennung/NennungsEingangScreen.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/nennung/NennungsEingangScreen.kt @@ -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") }