6 Commits

Author SHA1 Message Date
stefan 345c329350 chore: enhance Stammdaten-Verwaltung and refine desktop UX across multiple features, fix typo in settings.json, enable WASM builds, and add Master-Detail layout for Funktionäre
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
2026-04-20 02:49:34 +02:00
stefan d4aeba4666 chore: implementiere MsFilePicker-Komponente, ersetze veraltete Input-Felder in Geräteneukonfiguration und ZNS-Importer, verbessere Vereinskarten-Darstellung und Detail-UX, behebe Tippfehler in settings.json 2026-04-20 02:00:36 +02:00
stefan 9fe889b2c1 chore: bereinige unbenutzte Importe in VereinScreens.kt und LogoUploadZone.jvm.kt, verbessere Code-Readability durch konsistenten Importstil 2026-04-20 01:21:18 +02:00
stefan 85ac1cae9c chore: implementiere Logo-Upload-Zone mit Base64-Unterstützung, verbessere Vereinsverwaltung mit kompakten Feldern und nutzerspezifischen Uploadoptionen, optimiere Desktop-UX und Navigation 2026-04-20 01:20:20 +02:00
stefan dfaa2e8545 chore: consolidate redundant controllers in mail-service, improve backend stability, refine desktop UX, and enhance Vereinsverwaltung functionality 2026-04-20 00:21:20 +02:00
stefan bcabb86841 chore: fix DI binding for ZnsImportProvider in zns-import-feature, ensure proper Koin resolution 2026-04-19 22:18:18 +02:00
41 changed files with 2023 additions and 485 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,27 @@
# Journal: 19. April 2026 - Fix ZnsImportProvider DI-Binding
## 🏗️ Status Quo
Nach der Einführung der Entkopplung durch das `ZnsImportProvider` Interface am 17. April kam es beim Start der Desktop-App zu einem Koin-Fehler.
Die App brach ab mit: `No definition found for type 'at.mocode.frontend.core.domain.zns.ZnsImportProvider'`.
## ✅ Änderungen
### 1. Feature: ZNS-Import (Frontend)
- **Datei:** `frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/frontend/features/zns/import/di/ZnsImportModule.kt`
- **Fix:** Die Koin-Moduldefinition wurde korrigiert. `ZnsImportViewModel` wird nun explizit an das Interface `ZnsImportProvider` gebunden.
```kotlin
val znsImportModule = module {
factory { ZnsImportViewModel(get(named("apiClient")), get(), get()) } bind ZnsImportProvider::class
}
```
## 🚀 Ergebnis
Die Desktop-App kann nun wieder korrekt starten, da Koin das Interface `ZnsImportProvider` auflösen kann, welches in den UI-Komponenten (z. B. Wizards) injiziert wird.
## 🧹 Curator Hinweis
Dieser Fix schließt die am 17. April begonnene Integration der ZNS Cloud-Suche ab, indem die notwendige DI-Konfiguration für die Desktop-Shell nachgereicht wurde.
@@ -0,0 +1,28 @@
# Journal: 20. April 2026 - Desktop UX & Navigation Refinement
## 🏗️ Desktop-App: UX & Eingabe-Optimierung (Update)
* **Tastatur-Navigation (Fokus-Flow):**
* **Device-Setup:** Vollständiges Refactoring von `DeviceInitializationConfig.jvm.kt`. Ersetzung von `OutlinedTextField` durch `MsTextField`. Entfernung störender `onKeyEvent`-Handler zugunsten des nativen `ImeAction`-Flows. Tab und Enter funktionieren nun reibungslos.
* **Standardisierung:** Konsistente Nutzung von `MsTextField` in allen neuen Screens (`VeranstalterNeu`, `ZnsImport`).
* **MsFilePicker (Zentrale Komponente):**
* Einführung einer plattformübergreifenden `MsFilePicker`-Komponente.
* **Desktop (JVM):** Nutzt den nativen `FileDialog` für Dateiauswahlen (Look & Feel) und `JFileChooser` für Verzeichnisse.
* **Integration:** Ersetzt manuelle Picker-Logik im Device-Setup und ZNS-Importer.
* **ZNS-Importer Refinement:**
* Implementierung einer Fortschrittsanzeige (`LinearProgressIndicator`) mit Prozent- und Status-Details.
* Klarstellung der Dateiformate: Unterstützung sowohl für `ZNS.zip` als auch für einzelne `.dat` Dateien.
## 🧭 Navigation & Stabilität
* **Veranstalter-Profil (Vereins-Integration):**
* Integration einer detaillierten Vereins-Vorschau (Card) im `VeranstalterDetailScreen`.
* Navigation zum Vereins-Editor direkt aus dem Veranstalter-Profil ("Bearbeiten"-Button).
* **UI-Konsistenz:**
* Einführung eines einheitlichen "Zurück"-Buttons (Pfeil-Icon) in der Header-Zeile aller Detail- und Konfigurations-Screens.
* Kompakte Darstellung von Suchergebnissen in der Vereins-Suche (inkl. Logo-Vorschau).
## 🧹 Curator Hinweis
Die gemeldeten UX-Blocker in der Geräte-Konfiguration und bei der Veranstaltungs-Neuanlage sind behoben. Der neue Date-Picker erfüllt den Wunsch nach einer komfortableren Datumsauswahl und verhindert Tippfehler im Zeitraum-Format.
@@ -0,0 +1,36 @@
# Journal: Stammdaten-Management & Sidebar-Erweiterung (20. April 2026)
## 🏗️ [Lead Architect] & 🎨 [Frontend Expert] Bericht
### 🔍 Analyse & Zielsetzung
Der User wünschte eine bessere Zugänglichkeit des ZNS-Importers sowie eine konsistente Verwaltung aller Stammdaten-Kategorien (Reiter, Pferde, Richter/Funktionäre) nach dem Vorbild der Vereins-Verwaltung. Zudem wurde eine höhere Informationsdichte (kompakte Felder) gefordert.
### 🛠️ Umgesetzte Änderungen
#### 1. Sidebar (NavigationRail)
- **ZNS-Import:** Ein dediziertes Icon (`CloudDownload`) wurde in der Sidebar platziert, um den Import-Prozess jederzeit schnell erreichbar zu machen.
- **Stammdaten-Dropdown:** Ein neues Gruppen-Icon (`Storage`) bündelt nun die Kategorien:
- Vereine (`People`)
- Reiter (`Person`)
- Pferde (`Pets`)
- Richter/Funktionäre (`Gavel`)
- **Implementierung:** Nutzung von `DropdownMenu` und `DpOffset` für eine saubere Platzierung neben der Rail.
#### 2. Stammdaten-Screens (Pferde, Reiter, Funktionäre)
- **Konsistentes Pattern:** Alle drei Kategorien wurden auf das `MsMasterDetailLayout` umgestellt.
- **Links (Master):** Kompakte Liste mit Suche (`MsFilterBar`) und Datentabelle (`MsDataTable`).
- **Rechts (Detail):** Eine "Card-Vorschau" (ähnlich der Vereins-Card) zeigt die wichtigsten Daten auf einen Blick. Der Editor öffnet sich per Klick auf "Bearbeiten".
- **Kompakte UI:** Alle `MsTextField`-Komponenten in diesen Screens wurden auf `compact = true` umgestellt, um die geforderte Informationsdichte zu erreichen.
- **Funktionäre (Richter):** Ein neues, leistungsfähigeres `FunktionaerViewModel` und der entsprechende Screen wurden implementiert, um auch hier das Master-Detail-Muster zu nutzen (vorher nur einfache Tabelle).
#### 3. Core-Komponenten Refinement
- **`MsButton`:** Unterstützung für Icons hinzugefügt, um "Anlegen"-Aktionen visuell zu unterstreichen.
- **`MsDataTable`:** Unterstützung für `selectedItem` Highlights eingebaut, damit der User in der Liste sofort erkennt, welcher Datensatz rechts im Detail angezeigt wird.
### 🧹 Curator Journal
* **Status:** Alle Stammdaten-Kategorien folgen nun einem einheitlichen Architektur-Muster.
* **Navigations-Stabilität:** Alias-Routen in `AppScreen` und `DesktopMainLayout` wurden konsolidiert.
* **Technischer Schuldenabbau:** Veraltete Tabellen-Screens (`ManagementScreens.kt`) wurden für Pferde, Reiter und Richter durch die neuen Feature-Screens ersetzt.
---
**Nächster Schritt:** Im nächsten Stint folgt die Integration der Web-App (Stufe 2).
@@ -0,0 +1,53 @@
# Journal-Eintrag: Vereins-Verwaltung Erweiterung (Logo & Adresse)
**Datum:** 20. April 2026
**Status:** Abgeschlossen (Bugfix & Feature-Integration)
**Beteiligte Agenten:** 🏗️ [Lead Architect], 🎨 [Frontend Expert], 🧹 [Curator], 🧐 [QA Specialist]
## 📝 Zusammenfassung
Die Vereins-Verwaltung wurde um detaillierte Adressdaten und ein funktionales Logo-Management erweitert. Ein kritischer Bug, der zum Einfrieren der App beim Datei-Import führte, wurde behoben. Logos werden nun in der Vorschau korrekt gerendert.
## 🛠️ Technische Änderungen
### 0. Bugfix: Logo-Picker UI-Freeze
* **Problem:** Der `FileDialog` (AWT) blockierte den Main-Thread, was zum Einfrieren der App führte.
* **Lösung:** Auslagerung des Dialog-Aufrufs in einen asynchronen `Dispatchers.IO` Kontext in `LogoUploadZone.jvm.kt`.
* **Stabilität:** Integration von Try-Catch Blöcken und detailliertem Logging für den Datei-Import-Prozess.
### 1. Feature: Logo-Rendering (Base64)
* **Implementation:** Einführung einer `expect/actual` Funktion `decodeBase64ToImage`.
* **JVM-Logic:** Nutzung von `org.jetbrains.skia.Image` zur Dekodierung der Base64-Bytes in eine `ImageBitmap`.
* **UI-Integration:** Die `VereinCardPreview` rendert nun das Vereinslogo direkt aus dem gespeicherten Base64-String mittels `androidx.compose.foundation.Image`.
### 2. 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
* [x] Bugfix: Datei-Dialog friert die UI nicht mehr ein (IO-Dispatcher).
* [x] Feature: Base64-Logo wird in der Card-Vorschau gerendert.
* [x] Feature: Logging im ViewModel und Logo-Service implementiert.
* [x] UI: Kompakte Adressfelder und Google-Maps-Link funktionieren.
## 📌 Nächste Schritte
* Implementierung einer tatsächlichen Bild-Skalierung vor dem Base64-Encoding, um Datenbank-Größe zu optimieren.
* Finalisierung der Drag-and-Drop Logik (`onExternalDrag`), sobald Bibliotheks-Support stabil ist.
---
*Dokumentiert durch den Curator.*
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -1,12 +1,12 @@
package at.mocode.frontend.core.designsystem.components package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
enum class ButtonVariant { enum class ButtonVariant {
@@ -24,6 +24,7 @@ fun MsButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
variant: ButtonVariant = ButtonVariant.PRIMARY, variant: ButtonVariant = ButtonVariant.PRIMARY,
size: ButtonSize = ButtonSize.MEDIUM, size: ButtonSize = ButtonSize.MEDIUM,
icon: ImageVector? = null,
enabled: Boolean = true, enabled: Boolean = true,
isLoading: Boolean = false, isLoading: Boolean = false,
fullWidth: Boolean = false, fullWidth: Boolean = false,
@@ -44,34 +45,38 @@ fun MsButton(
onClick = onClick, onClick = onClick,
modifier = buttonModifier, modifier = buttonModifier,
enabled = enabled && !isLoading, enabled = enabled && !isLoading,
contentPadding = if (icon != null) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.ContentPadding,
colors = if (containerColor != null) ButtonDefaults.buttonColors(containerColor = containerColor) else ButtonDefaults.buttonColors() colors = if (containerColor != null) ButtonDefaults.buttonColors(containerColor = containerColor) else ButtonDefaults.buttonColors()
) { ) {
ButtonContent(text = text, isLoading = isLoading) ButtonContent(text = text, isLoading = isLoading, icon = icon)
} }
ButtonVariant.SECONDARY -> FilledTonalButton( ButtonVariant.SECONDARY -> FilledTonalButton(
onClick = onClick, onClick = onClick,
modifier = buttonModifier, modifier = buttonModifier,
enabled = enabled && !isLoading, enabled = enabled && !isLoading,
contentPadding = if (icon != null) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.ContentPadding,
colors = if (containerColor != null) ButtonDefaults.filledTonalButtonColors(containerColor = containerColor) else ButtonDefaults.filledTonalButtonColors() colors = if (containerColor != null) ButtonDefaults.filledTonalButtonColors(containerColor = containerColor) else ButtonDefaults.filledTonalButtonColors()
) { ) {
ButtonContent(text = text, isLoading = isLoading) ButtonContent(text = text, isLoading = isLoading, icon = icon)
} }
ButtonVariant.OUTLINE -> OutlinedButton( ButtonVariant.OUTLINE -> OutlinedButton(
onClick = onClick, onClick = onClick,
modifier = buttonModifier, modifier = buttonModifier,
enabled = enabled && !isLoading enabled = enabled && !isLoading,
contentPadding = if (icon != null) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.ContentPadding
) { ) {
ButtonContent(text = text, isLoading = isLoading) ButtonContent(text = text, isLoading = isLoading, icon = icon)
} }
ButtonVariant.TEXT -> TextButton( ButtonVariant.TEXT -> TextButton(
onClick = onClick, onClick = onClick,
modifier = buttonModifier, modifier = buttonModifier,
enabled = enabled && !isLoading enabled = enabled && !isLoading,
contentPadding = if (icon != null) ButtonDefaults.TextButtonWithIconContentPadding else ButtonDefaults.TextButtonContentPadding
) { ) {
ButtonContent(text = text, isLoading = isLoading) ButtonContent(text = text, isLoading = isLoading, icon = icon)
} }
} }
} }
@@ -79,16 +84,28 @@ fun MsButton(
@Composable @Composable
private fun ButtonContent( private fun ButtonContent(
text: String, text: String,
isLoading: Boolean isLoading: Boolean,
icon: ImageVector? = null
) { ) {
if (isLoading) { if (isLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.padding(2.dp), modifier = Modifier.size(18.dp),
strokeWidth = 2.dp strokeWidth = 2.dp,
color = LocalContentColor.current
) )
} else { } else {
Row(verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
}
Text(text) Text(text)
} }
}
} }
@Suppress("unused") @Suppress("unused")
@@ -57,6 +57,7 @@ fun <T> MsDataTable(
items: List<T>, items: List<T>,
columns: List<MsColumnDefinition<T>>, columns: List<MsColumnDefinition<T>>,
onRowClick: ((T) -> Unit)? = null, onRowClick: ((T) -> Unit)? = null,
selectedItem: T? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
headerBackgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant, headerBackgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant,
rowBackgroundColor: Color = MaterialTheme.colorScheme.surface, rowBackgroundColor: Color = MaterialTheme.colorScheme.surface,
@@ -100,7 +101,12 @@ fun <T> MsDataTable(
val state = androidx.compose.foundation.lazy.rememberLazyListState() val state = androidx.compose.foundation.lazy.rememberLazyListState()
LazyColumn(state = state, modifier = Modifier.fillMaxSize()) { LazyColumn(state = state, modifier = Modifier.fillMaxSize()) {
itemsIndexed(items) { index, item -> itemsIndexed(items) { index, item ->
val bgColor = if (index % 2 == 0) rowBackgroundColor else alternateRowBackgroundColor val isSelected = item == selectedItem
val bgColor = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
index % 2 == 0 -> rowBackgroundColor
else -> alternateRowBackgroundColor
}
Surface( Surface(
color = bgColor, color = bgColor,
@@ -0,0 +1,197 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Ein einfacher DatePicker-Dialog für Compose Desktop.
* Da Material3 DatePicker unter Desktop teils Probleme macht oder zu groß ist,
* nutzen wir hier eine kompakte Eigenimplementierung.
*/
@Composable
fun MsDatePickerField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
isError: Boolean = false,
errorMessage: String? = null
) {
var showDialog by remember { mutableStateOf(false) }
Box(modifier = modifier) {
MsTextField(
value = value,
onValueChange = { /* Schreibgeschützt via Dialog */ },
label = label,
placeholder = "YYYY-MM-DD",
readOnly = true,
isError = isError,
errorMessage = errorMessage,
trailingIcon = Icons.Default.CalendarMonth,
onTrailingIconClick = { showDialog = true },
modifier = Modifier.clickable { showDialog = true }
)
if (showDialog) {
MsDatePickerDialog(
initialDate = value,
onDismiss = { showDialog = false },
onDateSelected = {
onValueChange(it)
showDialog = false
}
)
}
}
}
@Composable
fun MsDatePickerDialog(
initialDate: String,
onDismiss: () -> Unit,
onDateSelected: (String) -> Unit
) {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val parsedDate = try {
LocalDate.parse(initialDate)
} catch (_: Exception) {
now
}
var currentMonth by remember { mutableStateOf(parsedDate.month) }
var currentYear by remember { mutableStateOf(parsedDate.year) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier.width(300.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(Modifier.padding(16.dp)) {
// Header: Monat/Jahr Auswahl
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
if (currentMonth == Month.JANUARY) {
currentMonth = Month.DECEMBER
currentYear--
} else {
val months = Month.entries
currentMonth = months[currentMonth.ordinal - 1]
}
}) {
Text("<")
}
Text(
"${currentMonth.name} $currentYear",
style = MaterialTheme.typography.titleMedium
)
IconButton(onClick = {
if (currentMonth == Month.DECEMBER) {
currentMonth = Month.JANUARY
currentYear++
} else {
val months = Month.entries
currentMonth = months[currentMonth.ordinal + 1]
}
}) {
Text(">")
}
}
Spacer(Modifier.height(8.dp))
// Kalender-Grid
val daysInMonth = getDaysInMonth(currentMonth, currentYear)
val firstDayOfWeek = LocalDate(currentYear, currentMonth, 1).dayOfWeek.ordinal // 0=Monday
Row(Modifier.fillMaxWidth()) {
listOf("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So").forEach {
Text(
it,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall,
color = Color.Gray,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
val totalSlots = 42 // 6 Wochen
Column {
for (week in 0 until 6) {
Row(Modifier.fillMaxWidth()) {
for (day in 0 until 7) {
val slotIndex = week * 7 + day
val dayNum = slotIndex - firstDayOfWeek + 1
if (dayNum in 1..daysInMonth) {
val isSelected = parsedDate.day == dayNum &&
parsedDate.month == currentMonth &&
parsedDate.year == currentYear
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.padding(2.dp)
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else Color.Transparent,
MaterialTheme.shapes.small
)
.clickable {
val selected = LocalDate(currentYear, currentMonth, dayNum)
onDateSelected(selected.toString())
},
contentAlignment = Alignment.Center
) {
Text(
dayNum.toString(),
style = MaterialTheme.typography.bodySmall,
color = if (isSelected) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface
)
}
} else {
Spacer(Modifier.weight(1f).aspectRatio(1f))
}
}
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) { Text("Abbrechen") }
}
}
}
}
}
private fun getDaysInMonth(month: Month, year: Int): Int {
return when (month) {
Month.FEBRUARY -> if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) 29 else 28
Month.APRIL, Month.JUNE, Month.SEPTEMBER, Month.NOVEMBER -> 30
else -> 31
}
}
@@ -0,0 +1,18 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Zentraler FilePicker für die gesamte App.
*/
@Composable
expect fun MsFilePicker(
label: String,
selectedPath: String?,
onFileSelected: (String) -> Unit,
fileExtensions: List<String> = emptyList(),
directoryOnly: Boolean = false,
enabled: Boolean = true,
modifier: Modifier = Modifier
)
@@ -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()) {
@@ -0,0 +1,78 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import javax.swing.JFileChooser
@Composable
actual fun MsFilePicker(
label: String,
selectedPath: String?,
onFileSelected: (String) -> Unit,
fileExtensions: List<String>,
directoryOnly: Boolean,
enabled: Boolean,
modifier: Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
MsTextField(
value = selectedPath ?: "",
onValueChange = { },
readOnly = true,
label = label,
placeholder = if (directoryOnly) "Verzeichnis wählen..." else "Datei wählen...",
modifier = Modifier.weight(1f),
enabled = enabled,
compact = true
)
Spacer(Modifier.width(8.dp))
MsButton(
onClick = {
if (directoryOnly) {
// JFileChooser ist für Verzeichnisse auf dem Desktop oft stabiler/einfacher
val chooser = JFileChooser().apply {
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
dialogTitle = label
selectedPath?.let {
val currentDir = File(it)
if (currentDir.exists()) currentDirectory = currentDir
}
}
val result = chooser.showOpenDialog(null)
if (result == JFileChooser.APPROVE_OPTION) {
onFileSelected(chooser.selectedFile.absolutePath)
}
} else {
// AWT FileDialog für nativen Look bei Dateiauswahl (wie vom User gewünscht)
val dialog = FileDialog(null as Frame?, label, FileDialog.LOAD).apply {
if (fileExtensions.isNotEmpty()) {
setFilenameFilter { _, name ->
fileExtensions.any { name.lowercase().endsWith(it.lowercase()) }
}
}
}
dialog.isVisible = true
if (dialog.file != null) {
onFileSelected(File(dialog.directory, dialog.file).absolutePath)
}
}
},
text = "Durchsuchen",
enabled = enabled
)
}
}
@@ -0,0 +1,25 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
actual fun MsFilePicker(
label: String,
selectedPath: String?,
onFileSelected: (String) -> Unit,
fileExtensions: List<String>,
directoryOnly: Boolean,
enabled: Boolean,
modifier: Modifier
) {
// WasmJs Implementierung (Platzhalter oder HTML Input Logik)
MsTextField(
value = selectedPath ?: "",
onValueChange = { },
readOnly = true,
label = label,
modifier = modifier,
enabled = enabled
)
}
@@ -4,11 +4,9 @@ package at.mocode.frontend.features.device.initialization.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -31,11 +29,10 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsEnumDropdown import at.mocode.frontend.core.designsystem.components.MsEnumDropdown
import at.mocode.frontend.core.designsystem.components.MsFilePicker
import at.mocode.frontend.core.designsystem.components.MsTextField
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
import java.io.File
import javax.swing.JFileChooser
import javax.swing.UIManager
@Composable @Composable
actual fun DeviceInitializationConfig( actual fun DeviceInitializationConfig(
@@ -54,37 +51,28 @@ actual fun DeviceInitializationConfig(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("⚙️ Geräte-Konfiguration (${settings.networkRole})", style = MaterialTheme.typography.titleMedium) Text("⚙️ Geräte-Konfiguration (${settings.networkRole})", style = MaterialTheme.typography.titleMedium)
MsSettingsField( MsTextField(
value = settings.deviceName, value = settings.deviceName,
onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } },
label = "Gerätename", label = "Gerätename",
placeholder = "z.B. Meldestelle-PC-1", placeholder = "z.B. Meldestelle-PC-1",
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName), isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.", errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), 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) }
MsSettingsField( MsTextField(
value = settings.sharedKey, value = settings.sharedKey,
onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } }, onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } },
label = "Sicherheitsschlüssel (Sync-Key)", label = "Sicherheitsschlüssel (Sync-Key)",
placeholder = "Mindestens 8 Zeichen", placeholder = "Mindestens 8 Zeichen",
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey), isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
errorText = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.", errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions( imeAction = if (settings.networkRole == NetworkRole.MASTER) ImeAction.Next else ImeAction.Done,
imeAction = if (settings.networkRole == NetworkRole.MASTER) ImeAction.Next else ImeAction.Done
),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) }, onNext = { focusManager.moveFocus(FocusDirection.Next) },
onDone = { onDone = {
@@ -95,58 +83,20 @@ actual fun DeviceInitializationConfig(
} }
} }
), ),
modifier = Modifier.focusRequester(sharedKeyFocus).onKeyEvent { modifier = Modifier.focusRequester(sharedKeyFocus),
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
if (settings.networkRole == NetworkRole.MASTER) { onTrailingIconClick = { passwordVisible = !passwordVisible }
focusManager.moveFocus(FocusDirection.Next)
} else if (DeviceInitializationValidator.canContinue(settings)) {
viewModel.completeInitialization()
}
true
} else {
false
}
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (passwordVisible) "Verbergen" else "Anzeigen"
)
}
}
) )
if (settings.networkRole == NetworkRole.MASTER) { if (settings.networkRole == NetworkRole.MASTER) {
OutlinedTextField( MsFilePicker(
value = settings.backupPath, label = "Backup-Verzeichnis (Pfad)",
onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } }, selectedPath = settings.backupPath,
label = { Text("Backup-Verzeichnis (Pfad)") }, onFileSelected = { selectedPath ->
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) } viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
}
true
} else {
false
}
}, },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), directoryOnly = true,
keyboardActions = KeyboardActions( modifier = Modifier.focusRequester(backupPathFocus)
onNext = { focusManager.moveFocus(FocusDirection.Next) }
),
trailingIcon = {
IconButton(onClick = {
selectBackupPath(settings.backupPath) { selectedPath ->
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
}
}) {
Icon(Icons.Outlined.FolderOpen, contentDescription = "Verzeichnis wählen")
}
},
isError = settings.backupPath.isNotEmpty() && !DeviceInitializationValidator.isBackupPathValid(settings.backupPath)
) )
Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium) Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium)
@@ -320,12 +270,12 @@ private fun ClientEntryRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
OutlinedTextField( MsTextField(
value = name, value = name,
onValueChange = onNameChange, onValueChange = onNameChange,
label = { Text("Gerätename des Clients") }, label = "Gerätename des Clients",
modifier = Modifier.weight(1f).focusRequester(clientNameFocus), modifier = Modifier.weight(1f).focusRequester(clientNameFocus),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }) keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
) )
@@ -345,58 +295,3 @@ private fun ClientEntryRow(
) )
} }
} }
@Composable
private fun MsSettingsField(
value: String,
onValueChange: (String) -> Unit,
label: String,
placeholder: String,
isError: Boolean,
errorText: String,
modifier: Modifier = Modifier,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
trailingIcon: @Composable (() -> Unit)? = null
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = { Text(placeholder) },
modifier = modifier.fillMaxWidth(),
isError = isError,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
trailingIcon = trailingIcon,
supportingText = {
if (isError) {
Text(errorText)
}
}
)
}
private fun selectBackupPath(currentPath: String, onPathSelected: (String) -> Unit) {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
val chooser = JFileChooser().apply {
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
dialogTitle = "Backup-Verzeichnis wählen"
if (currentPath.isNotEmpty()) {
val currentDir = File(currentPath)
if (currentDir.exists()) currentDirectory = currentDir
}
}
val result = chooser.showOpenDialog(null)
if (result == JFileChooser.APPROVE_OPTION) {
val selectedPath = chooser.selectedFile.absolutePath
onPathSelected(selectedPath)
println("[DeviceInit] Backup-Verzeichnis gewählt: $selectedPath")
}
} catch (e: Exception) {
println("[DeviceInit] [Error] Fehler beim Öffnen des Verzeichnis-Wählers: ${e.message}")
}
}
@@ -1,5 +1,6 @@
package at.mocode.frontend.features.funktionaer.di package at.mocode.frontend.features.funktionaer.di
import at.mocode.frontend.features.funktionaer.domain.Funktionaer
import at.mocode.frontend.features.funktionaer.presentation.* import at.mocode.frontend.features.funktionaer.presentation.*
import org.koin.dsl.module import org.koin.dsl.module
@@ -9,9 +10,9 @@ val funktionaerModule = module {
} }
class MockFunktionaerRepository : FunktionaerRepository { class MockFunktionaerRepository : FunktionaerRepository {
override suspend fun list(): List<FunktionaerListItem> = listOf( override suspend fun list(): List<Funktionaer> = listOf(
FunktionaerListItem(1, "Wolfgang Schier", "RICHTER", "G3"), Funktionaer(1, "Wolfgang", "Schier", "12345", listOf("RICHTER"), "G3"),
FunktionaerListItem(2, "Alice Schwab", "RICHTER", "INTERNATIONAL"), Funktionaer(2, "Alice", "Schwab", "23456", listOf("RICHTER"), "INTERNATIONAL"),
FunktionaerListItem(3, "Dietmar Gstöttner", "PARCOURSBAUER", null) Funktionaer(3, "Dietmar", "Gstöttner", "34567", listOf("PARCOURSBAUER"), null)
) )
} }
@@ -1,17 +1,20 @@
package at.mocode.frontend.features.funktionaer.presentation package at.mocode.frontend.features.funktionaer.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material.icons.Icons
import androidx.compose.material3.MaterialTheme import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Text import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.funktionaer.domain.Funktionaer
@Composable @Composable
fun FunktionaerScreen( fun FunktionaerScreen(
@@ -24,19 +27,31 @@ fun FunktionaerScreen(
FunktionaerListContent( FunktionaerListContent(
state = state, state = state,
onSearchChange = { viewModel.send(FunktionaerIntent.SearchChanged(it)) }, onSearchChange = { viewModel.send(FunktionaerIntent.SearchChanged(it)) },
onFunktionaerSelected = { viewModel.send(FunktionaerIntent.Select(it)) } onFunktionaerSelected = { viewModel.send(FunktionaerIntent.Select(it)) },
onAddNew = { viewModel.send(FunktionaerIntent.AddNew) }
) )
}, },
detail = { detail = {
if (state.selectedId != null) { if (state.isEditing) {
val selected = state.list.find { it.id == state.selectedId } FunktionaerEditorContent(
if (selected != null) { state = state,
FunktionaerDetailContent(selected) onVornameChange = { viewModel.send(FunktionaerIntent.EditVorname(it)) },
} onNachnameChange = { viewModel.send(FunktionaerIntent.EditNachname(it)) },
onRichterNummerChange = { viewModel.send(FunktionaerIntent.EditRichterNummer(it)) },
onEmailChange = { viewModel.send(FunktionaerIntent.EditEmail(it)) },
onTelefonChange = { viewModel.send(FunktionaerIntent.EditTelefon(it)) },
onSave = { viewModel.send(FunktionaerIntent.Save) },
onCancel = { viewModel.send(FunktionaerIntent.Cancel) }
)
} else if (state.selectedFunktionaer != null) {
FunktionaerCard(
funktionaer = state.selectedFunktionaer!!,
onEdit = { viewModel.send(FunktionaerIntent.Select(state.selectedFunktionaer)) }
)
} else { } else {
PlaceholderContent( PlaceholderContent(
title = "Kein Funktionär ausgewählt", title = "Kein Funktionär ausgewählt",
subtitle = "Wählen Sie einen Funktionär aus der Liste aus." subtitle = "Wählen Sie einen Richter oder Funktionär aus der Liste aus."
) )
} }
} }
@@ -47,13 +62,21 @@ fun FunktionaerScreen(
private fun FunktionaerListContent( private fun FunktionaerListContent(
state: FunktionaerState, state: FunktionaerState,
onSearchChange: (String) -> Unit, onSearchChange: (String) -> Unit,
onFunktionaerSelected: (Long) -> Unit onFunktionaerSelected: (Funktionaer) -> Unit,
onAddNew: () -> Unit
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
MsFilterBar( MsFilterBar(
searchQuery = state.searchQuery, searchQuery = state.searchQuery,
onSearchQueryChange = onSearchChange, onSearchQueryChange = onSearchChange,
resultCount = state.filtered.size resultCount = state.filtered.size,
actions = {
MsButton(
text = "Funktionär anlegen",
onClick = onAddNew,
icon = Icons.Default.Add
)
}
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -68,36 +91,189 @@ private fun FunktionaerListContent(
columns = listOf( columns = listOf(
MsColumnDefinition( MsColumnDefinition(
title = "Name", title = "Name",
weight = 1.5f,
cellRenderer = { Text("${it.vorname} ${it.nachname}", style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Nr.",
width = 80.dp,
cellRenderer = { Text(it.richterNummer ?: "-", style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Rollen",
weight = 1f, weight = 1f,
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) } cellRenderer = { Text(it.rollen.joinToString(", "), style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Rolle",
width = 150.dp,
cellRenderer = { Text(it.rolle, style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Lizenz",
width = 100.dp,
cellRenderer = { Text(it.lizenz ?: "-", style = MaterialTheme.typography.bodySmall) }
) )
), ),
onRowClick = { onFunktionaerSelected(it.id) } onRowClick = onFunktionaerSelected,
selectedItem = state.selectedFunktionaer
) )
} }
} }
} }
@Composable @Composable
private fun FunktionaerDetailContent(item: FunktionaerListItem) { fun FunktionaerCard(
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { funktionaer: Funktionaer,
Text(item.name, style = MaterialTheme.typography.headlineMedium) onEdit: () -> Unit
Spacer(Modifier.height(8.dp)) ) {
Text("Rolle: ${item.rolle}", style = MaterialTheme.typography.bodyLarge) Column(
item.lizenz?.let { modifier = Modifier.fillMaxSize().padding(16.dp),
Text("Lizenz: $it", style = MaterialTheme.typography.bodyLarge) verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(modifier = Modifier.padding(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
modifier = Modifier.size(48.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Gavel,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
} }
}
Spacer(Modifier.width(16.dp))
Column {
Text(
"${funktionaer.vorname} ${funktionaer.nachname}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
"Richter-Nr: ${funktionaer.richterNummer ?: "-"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
MsStatusBadge(
text = if (funktionaer.istAktiv) "Aktiv" else "Inaktiv",
containerColor = (if (funktionaer.istAktiv) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error).copy(alpha = 0.1f),
contentColor = if (funktionaer.istAktiv) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text("Weitere Details folgen in der nächsten Ausbaustufe.", style = MaterialTheme.typography.bodyMedium) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth()) {
FunktionaerDetailItem(label = "Rollen", value = funktionaer.rollen.joinToString(", "), modifier = Modifier.weight(1f))
FunktionaerDetailItem(label = "Qualifikation", value = funktionaer.richterQualifikation ?: "-", modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
FunktionaerDetailItem(label = "E-Mail", value = funktionaer.email ?: "-", modifier = Modifier.weight(1f))
FunktionaerDetailItem(label = "Telefon", value = funktionaer.telefon ?: "-", modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(32.dp))
MsButton(
text = "Daten bearbeiten",
onClick = onEdit,
fullWidth = true
)
}
}
}
}
@Composable
private fun FunktionaerDetailItem(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
}
}
@Composable
private fun FunktionaerEditorContent(
state: FunktionaerState,
onVornameChange: (String) -> Unit,
onNachnameChange: (String) -> Unit,
onRichterNummerChange: (String) -> Unit,
onEmailChange: (String) -> Unit,
onTelefonChange: (String) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
MsActionToolbar(
title = "Funktionär Details",
onSave = onSave,
onCancel = onCancel
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = state.editVorname,
onValueChange = onVornameChange,
label = "Vorname",
modifier = Modifier.weight(1f),
compact = true
)
MsTextField(
value = state.editNachname,
onValueChange = onNachnameChange,
label = "Nachname",
modifier = Modifier.weight(1f),
compact = true
)
}
Spacer(Modifier.height(16.dp))
MsTextField(
value = state.editRichterNummer,
onValueChange = onRichterNummerChange,
label = "Richter-Nummer",
modifier = Modifier.width(300.dp),
compact = true
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = state.editEmail,
onValueChange = onEmailChange,
label = "E-Mail",
modifier = Modifier.weight(1f),
compact = true
)
MsTextField(
value = state.editTelefon,
onValueChange = onTelefonChange,
label = "Telefon",
modifier = Modifier.weight(1f),
compact = true
)
}
Spacer(Modifier.height(24.dp))
Text("Zusätzliche Qualifikationen und Rollen werden über das ZNS-System synchronisiert.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
@@ -1,8 +1,8 @@
package at.mocode.frontend.features.funktionaer.presentation package at.mocode.frontend.features.funktionaer.presentation
import kotlinx.coroutines.CoroutineScope import at.mocode.frontend.features.funktionaer.domain.Funktionaer
import kotlinx.coroutines.Dispatchers import androidx.lifecycle.ViewModel
import kotlinx.coroutines.SupervisorJob import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -17,9 +17,15 @@ data class FunktionaerListItem(
data class FunktionaerState( data class FunktionaerState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val searchQuery: String = "", val searchQuery: String = "",
val list: List<FunktionaerListItem> = emptyList(), val list: List<Funktionaer> = emptyList(),
val filtered: List<FunktionaerListItem> = emptyList(), val filtered: List<Funktionaer> = emptyList(),
val selectedId: Long? = null, val selectedFunktionaer: Funktionaer? = null,
val isEditing: Boolean = false,
val editVorname: String = "",
val editNachname: String = "",
val editRichterNummer: String = "",
val editEmail: String = "",
val editTelefon: String = "",
val errorMessage: String? = null, val errorMessage: String? = null,
) )
@@ -27,19 +33,25 @@ sealed interface FunktionaerIntent {
data object Load : FunktionaerIntent data object Load : FunktionaerIntent
data object Refresh : FunktionaerIntent data object Refresh : FunktionaerIntent
data class SearchChanged(val query: String) : FunktionaerIntent data class SearchChanged(val query: String) : FunktionaerIntent
data class Select(val id: Long?) : FunktionaerIntent data class Select(val funktionaer: Funktionaer?) : FunktionaerIntent
data object AddNew : FunktionaerIntent
data class EditVorname(val value: String) : FunktionaerIntent
data class EditNachname(val value: String) : FunktionaerIntent
data class EditRichterNummer(val value: String) : FunktionaerIntent
data class EditEmail(val value: String) : FunktionaerIntent
data class EditTelefon(val value: String) : FunktionaerIntent
data object Save : FunktionaerIntent
data object Cancel : FunktionaerIntent
data object ClearError : FunktionaerIntent data object ClearError : FunktionaerIntent
} }
interface FunktionaerRepository { interface FunktionaerRepository {
suspend fun list(): List<FunktionaerListItem> suspend fun list(): List<Funktionaer>
} }
class FunktionaerViewModel( class FunktionaerViewModel(
private val repo: FunktionaerRepository, private val repo: FunktionaerRepository,
) { ) : ViewModel() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(FunktionaerState(isLoading = true)) private val _state = MutableStateFlow(FunktionaerState(isLoading = true))
val state: StateFlow<FunktionaerState> = _state val state: StateFlow<FunktionaerState> = _state
@@ -49,14 +61,44 @@ class FunktionaerViewModel(
when (intent) { when (intent) {
is FunktionaerIntent.Load, is FunktionaerIntent.Refresh -> load() is FunktionaerIntent.Load, is FunktionaerIntent.Refresh -> load()
is FunktionaerIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() } is FunktionaerIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is FunktionaerIntent.Select -> reduce { it.copy(selectedId = intent.id) } is FunktionaerIntent.Select -> reduce {
it.copy(
selectedFunktionaer = intent.funktionaer,
isEditing = intent.funktionaer != null,
editVorname = intent.funktionaer?.vorname ?: "",
editNachname = intent.funktionaer?.nachname ?: "",
editRichterNummer = intent.funktionaer?.richterNummer ?: "",
editEmail = intent.funktionaer?.email ?: "",
editTelefon = intent.funktionaer?.telefon ?: ""
)
}
is FunktionaerIntent.AddNew -> reduce {
it.copy(
selectedFunktionaer = null,
isEditing = true,
editVorname = "",
editNachname = "",
editRichterNummer = "",
editEmail = "",
editTelefon = ""
)
}
is FunktionaerIntent.EditVorname -> reduce { it.copy(editVorname = intent.value) }
is FunktionaerIntent.EditNachname -> reduce { it.copy(editNachname = intent.value) }
is FunktionaerIntent.EditRichterNummer -> reduce { it.copy(editRichterNummer = intent.value) }
is FunktionaerIntent.EditEmail -> reduce { it.copy(editEmail = intent.value) }
is FunktionaerIntent.EditTelefon -> reduce { it.copy(editTelefon = intent.value) }
is FunktionaerIntent.Save -> reduce { it.copy(isEditing = false) }
is FunktionaerIntent.Cancel -> reduce { it.copy(isEditing = false) }
is FunktionaerIntent.ClearError -> reduce { it.copy(errorMessage = null) } is FunktionaerIntent.ClearError -> reduce { it.copy(errorMessage = null) }
} }
} }
private fun load() { private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) } reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch { viewModelScope.launch {
try { try {
val items = repo.list() val items = repo.list()
reduce { cur -> reduce { cur ->
@@ -75,13 +117,13 @@ class FunktionaerViewModel(
reduce { it.copy(filtered = filtered) } reduce { it.copy(filtered = filtered) }
} }
private fun filterList(list: List<FunktionaerListItem>, query: String): List<FunktionaerListItem> { private fun filterList(list: List<Funktionaer>, query: String): List<Funktionaer> {
if (query.isBlank()) return list if (query.isBlank()) return list
val q = query.trim() val q = query.trim()
return list.filter { return list.filter {
it.name.contains(q, ignoreCase = true) || it.vorname.contains(q, ignoreCase = true) ||
it.rolle.contains(q, ignoreCase = true) || it.nachname.contains(q, ignoreCase = true) ||
(it.lizenz?.contains(q, ignoreCase = true) ?: false) (it.richterNummer?.contains(q, ignoreCase = true) ?: false)
} }
} }
@@ -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)
@@ -1,11 +1,14 @@
package at.mocode.frontend.features.pferde.presentation package at.mocode.frontend.features.pferde.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme import androidx.compose.material.icons.Icons
import androidx.compose.material3.Surface import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Text import androidx.compose.material.icons.filled.Pets
import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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
@@ -24,7 +27,8 @@ fun PferdeScreen(
PferdeListContent( PferdeListContent(
uiState = uiState, uiState = uiState,
onSearchChange = viewModel::onSearchQueryChange, onSearchChange = viewModel::onSearchQueryChange,
onPferdSelected = viewModel::selectPferd onPferdSelected = viewModel::selectPferd,
onAddNew = { viewModel.addNewPferd() }
) )
}, },
detail = { detail = {
@@ -43,6 +47,11 @@ fun PferdeScreen(
onSave = viewModel::onSave, onSave = viewModel::onSave,
onCancel = viewModel::onCancel onCancel = viewModel::onCancel
) )
} else if (uiState.selectedPferd != null) {
PferdCard(
pferd = uiState.selectedPferd,
onEdit = { viewModel.selectPferd(uiState.selectedPferd) }
)
} else { } else {
PlaceholderContent( PlaceholderContent(
title = "Kein Pferd ausgewählt", title = "Kein Pferd ausgewählt",
@@ -57,13 +66,21 @@ fun PferdeScreen(
private fun PferdeListContent( private fun PferdeListContent(
uiState: PferdeUiState, uiState: PferdeUiState,
onSearchChange: (String) -> Unit, onSearchChange: (String) -> Unit,
onPferdSelected: (Pferd) -> Unit onPferdSelected: (Pferd) -> Unit,
onAddNew: () -> Unit
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
MsFilterBar( MsFilterBar(
searchQuery = uiState.searchQuery, searchQuery = uiState.searchQuery,
onSearchQueryChange = onSearchChange, onSearchQueryChange = onSearchChange,
resultCount = uiState.searchResults.size resultCount = uiState.searchResults.size,
actions = {
MsButton(
onClick = onAddNew,
text = "Pferd anlegen",
icon = Icons.Default.Add
)
}
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -77,9 +94,9 @@ private fun PferdeListContent(
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) } cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) }
), ),
MsColumnDefinition( MsColumnDefinition(
title = "Lebensnummer", title = "ÖPS-Nr.",
width = 150.dp, width = 100.dp,
cellRenderer = { Text(it.lebensnummer, style = MaterialTheme.typography.bodySmall) } cellRenderer = { Text(it.oepsNummer ?: "-", style = MaterialTheme.typography.bodySmall) }
), ),
MsColumnDefinition( MsColumnDefinition(
title = "Status", title = "Status",
@@ -93,11 +110,114 @@ private fun PferdeListContent(
} }
) )
), ),
onRowClick = onPferdSelected onRowClick = onPferdSelected,
selectedItem = uiState.selectedPferd
) )
} }
} }
@Composable
fun PferdCard(
pferd: Pferd,
onEdit: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(modifier = Modifier.padding(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
modifier = Modifier.size(48.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Pets,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(Modifier.width(16.dp))
Column {
Text(
pferd.name,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
pferd.lebensnummer,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
MsStatusBadge(
text = pferd.status.label,
containerColor = pferd.status.color.copy(alpha = 0.1f),
contentColor = pferd.status.color
)
}
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth()) {
DetailItem(label = "ÖPS-Nr.", value = pferd.oepsNummer ?: "-", modifier = Modifier.weight(1f))
DetailItem(label = "FEI-ID", value = pferd.feiId ?: "-", modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
DetailItem(label = "Geschlecht", value = pferd.geschlecht.label, modifier = Modifier.weight(1f))
DetailItem(label = "Farbe", value = pferd.farbe, modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
DetailItem(label = "Geburtsjahr", value = pferd.geburtsjahr?.toString() ?: "-", modifier = Modifier.weight(1f))
DetailItem(label = "Besitzer", value = pferd.besitzer ?: "-", modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(32.dp))
MsButton(
onClick = onEdit,
text = "Pferdedaten bearbeiten",
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
@Composable
private fun DetailItem(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
}
}
@Composable @Composable
private fun PferdeEditorContent( private fun PferdeEditorContent(
uiState: PferdeUiState, uiState: PferdeUiState,
@@ -127,13 +247,15 @@ private fun PferdeEditorContent(
value = uiState.editName, value = uiState.editName,
onValueChange = onNameChange, onValueChange = onNameChange,
label = "Name", label = "Name",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
MsTextField( MsTextField(
value = uiState.editLebensnummer, value = uiState.editLebensnummer,
onValueChange = onLebensnummerChange, onValueChange = onLebensnummerChange,
label = "Lebensnummer", label = "Lebensnummer",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
} }
@@ -144,13 +266,15 @@ private fun PferdeEditorContent(
value = uiState.editFeiId, value = uiState.editFeiId,
onValueChange = onFeiIdChange, onValueChange = onFeiIdChange,
label = "FEI ID", label = "FEI ID",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
MsTextField( MsTextField(
value = uiState.editOepsNummer, value = uiState.editOepsNummer,
onValueChange = onOepsNummerChange, onValueChange = onOepsNummerChange,
label = "ÖPS Nummer", label = "ÖPS Nummer",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
} }
@@ -169,7 +293,8 @@ private fun PferdeEditorContent(
value = uiState.editFarbe, value = uiState.editFarbe,
onValueChange = onFarbeChange, onValueChange = onFarbeChange,
label = "Farbe", label = "Farbe",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
} }
@@ -180,13 +305,15 @@ private fun PferdeEditorContent(
value = uiState.editGeburtsjahr, value = uiState.editGeburtsjahr,
onValueChange = onGeburtsjahrChange, onValueChange = onGeburtsjahrChange,
label = "Geburtsjahr", label = "Geburtsjahr",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
MsTextField( MsTextField(
value = uiState.editBesitzer, value = uiState.editBesitzer,
onValueChange = onBesitzerChange, onValueChange = onBesitzerChange,
label = "Besitzer", label = "Besitzer",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
} }
@@ -17,6 +17,7 @@ data class PferdeUiState(
val selectedPferd: Pferd? = null, val selectedPferd: Pferd? = null,
val isEditing: Boolean = false, val isEditing: Boolean = false,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val editId: String = "",
val editName: String = "", val editName: String = "",
val editLebensnummer: String = "", val editLebensnummer: String = "",
val editGeschlecht: Geschlecht = Geschlecht.WALLACH, val editGeschlecht: Geschlecht = Geschlecht.WALLACH,
@@ -59,6 +60,7 @@ open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
uiState = uiState.copy( uiState = uiState.copy(
selectedPferd = pferd, selectedPferd = pferd,
isEditing = true, isEditing = true,
editId = pferd.id,
editName = pferd.name, editName = pferd.name,
editLebensnummer = pferd.lebensnummer, editLebensnummer = pferd.lebensnummer,
editGeschlecht = pferd.geschlecht, editGeschlecht = pferd.geschlecht,
@@ -71,6 +73,23 @@ open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
) )
} }
fun addNewPferd() {
uiState = uiState.copy(
selectedPferd = null,
isEditing = true,
editId = "",
editName = "",
editLebensnummer = "",
editGeschlecht = Geschlecht.WALLACH,
editFarbe = "",
editGeburtsjahr = "",
editStatus = PferdeStatus.AKTIV,
editFeiId = "",
editOepsNummer = "",
editBesitzer = ""
)
}
fun onEditFeiIdChange(value: String) { fun onEditFeiIdChange(value: String) {
uiState = uiState.copy(editFeiId = value) uiState = uiState.copy(editFeiId = value)
} }
@@ -1,9 +1,9 @@
package at.mocode.frontend.features.reiter.presentation package at.mocode.frontend.features.reiter.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.*
@@ -23,7 +23,8 @@ fun ReiterScreen(
ReiterListContent( ReiterListContent(
uiState = uiState, uiState = uiState,
onSearchChange = viewModel::onSearchQueryChange, onSearchChange = viewModel::onSearchQueryChange,
onReiterSelected = viewModel::selectReiter onReiterSelected = viewModel::selectReiter,
onAddNew = { viewModel.addNewReiter() }
) )
}, },
detail = { detail = {
@@ -43,6 +44,11 @@ fun ReiterScreen(
onSave = viewModel::onSave, onSave = viewModel::onSave,
onCancel = viewModel::onCancel onCancel = viewModel::onCancel
) )
} else if (uiState.selectedReiter != null) {
ReiterCard(
reiter = uiState.selectedReiter,
onEdit = { viewModel.selectReiter(uiState.selectedReiter) }
)
} else { } else {
PlaceholderContent( PlaceholderContent(
title = "Kein Reiter ausgewählt", title = "Kein Reiter ausgewählt",
@@ -57,13 +63,20 @@ fun ReiterScreen(
private fun ReiterListContent( private fun ReiterListContent(
uiState: ReiterUiState, uiState: ReiterUiState,
onSearchChange: (String) -> Unit, onSearchChange: (String) -> Unit,
onReiterSelected: (Reiter) -> Unit onReiterSelected: (Reiter) -> Unit,
onAddNew: () -> Unit
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
MsFilterBar( MsFilterBar(
searchQuery = uiState.searchQuery, searchQuery = uiState.searchQuery,
onSearchQueryChange = onSearchChange, onSearchQueryChange = onSearchChange,
resultCount = uiState.searchResults.size resultCount = uiState.searchResults.size,
actions = {
MsButton(
text = "Reiter anlegen",
onClick = onAddNew
)
}
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -72,14 +85,9 @@ private fun ReiterListContent(
items = uiState.searchResults, items = uiState.searchResults,
columns = listOf( columns = listOf(
MsColumnDefinition( MsColumnDefinition(
title = "Vorname", title = "Name",
weight = 1f, weight = 1.5f,
cellRenderer = { Text(it.vorname, style = MaterialTheme.typography.bodySmall) } cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Nachname",
weight = 1f,
cellRenderer = { Text(it.nachname, style = MaterialTheme.typography.bodySmall) }
), ),
MsColumnDefinition( MsColumnDefinition(
title = "Lizenz", title = "Lizenz",
@@ -103,6 +111,107 @@ private fun ReiterListContent(
} }
} }
@Composable
fun ReiterCard(
reiter: Reiter,
onEdit: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(modifier = Modifier.padding(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
modifier = Modifier.size(48.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = (reiter.vorname.take(1) + reiter.nachname.take(1)).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(Modifier.width(16.dp))
Column {
Text(
reiter.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"ÖPS-Nr: ${reiter.oepsNummer ?: "-"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
MsStatusBadge(
text = reiter.status.label,
containerColor = reiter.status.color.copy(alpha = 0.1f),
contentColor = reiter.status.color
)
}
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth()) {
ReiterDetailItem(label = "Lizenz", value = reiter.lizenz.label, modifier = Modifier.weight(1f))
ReiterDetailItem(label = "Hauptsparte", value = reiter.sparte.label, modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
ReiterDetailItem(label = "E-Mail", value = reiter.email ?: "-", modifier = Modifier.weight(1f))
ReiterDetailItem(label = "Telefon", value = reiter.telefon ?: "-", modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
ReiterDetailItem(label = "Verein", value = reiter.verein ?: "-", modifier = Modifier.weight(1f))
ReiterDetailItem(label = "FEI-ID", value = reiter.feiId ?: "-", modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(32.dp))
MsButton(
text = "Reiterdaten bearbeiten",
onClick = onEdit,
fullWidth = true
)
}
}
}
}
@Composable
private fun ReiterDetailItem(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
}
}
@Composable @Composable
private fun ReiterEditorContent( private fun ReiterEditorContent(
uiState: ReiterUiState, uiState: ReiterUiState,
@@ -133,13 +242,15 @@ private fun ReiterEditorContent(
value = uiState.editVorname, value = uiState.editVorname,
onValueChange = onVornameChange, onValueChange = onVornameChange,
label = "Vorname", label = "Vorname",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
MsTextField( MsTextField(
value = uiState.editName, value = uiState.editName,
onValueChange = onNachnameChange, onValueChange = onNachnameChange,
label = "Nachname", label = "Nachname",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
} }
@@ -150,13 +261,15 @@ private fun ReiterEditorContent(
value = uiState.editFeiId, value = uiState.editFeiId,
onValueChange = onFeiIdChange, onValueChange = onFeiIdChange,
label = "FEI ID", label = "FEI ID",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
MsTextField( MsTextField(
value = uiState.editOepsNummer, value = uiState.editOepsNummer,
onValueChange = onOepsNummerChange, onValueChange = onOepsNummerChange,
label = "ÖPS Nummer", label = "ÖPS Nummer",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
} }
@@ -167,13 +280,15 @@ private fun ReiterEditorContent(
value = uiState.editGeburtsdatum, value = uiState.editGeburtsdatum,
onValueChange = onGeburtsdatumChange, onValueChange = onGeburtsdatumChange,
label = "Geburtsdatum", label = "Geburtsdatum",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
MsTextField( MsTextField(
value = uiState.editVerein, value = uiState.editVerein,
onValueChange = onVereinChange, onValueChange = onVereinChange,
label = "Verein", label = "Verein",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
} }
@@ -184,13 +299,15 @@ private fun ReiterEditorContent(
value = uiState.editEmail, value = uiState.editEmail,
onValueChange = onEmailChange, onValueChange = onEmailChange,
label = "E-Mail", label = "E-Mail",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
MsTextField( MsTextField(
value = uiState.editTelefon, value = uiState.editTelefon,
onValueChange = onTelefonChange, onValueChange = onTelefonChange,
label = "Telefon", label = "Telefon",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
compact = true
) )
} }
@@ -18,6 +18,7 @@ data class ReiterUiState(
val selectedReiter: Reiter? = null, val selectedReiter: Reiter? = null,
val isEditing: Boolean = false, val isEditing: Boolean = false,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val editId: String = "",
val editName: String = "", val editName: String = "",
val editVorname: String = "", val editVorname: String = "",
val editLizenz: LizenzKlasse = LizenzKlasse.KEINE, val editLizenz: LizenzKlasse = LizenzKlasse.KEINE,
@@ -65,6 +66,7 @@ open class ReiterViewModel(initialLoad: Boolean = true) : ViewModel() {
uiState = uiState.copy( uiState = uiState.copy(
selectedReiter = reiter, selectedReiter = reiter,
isEditing = true, isEditing = true,
editId = reiter.id,
editVorname = reiter.vorname, editVorname = reiter.vorname,
editName = reiter.nachname, editName = reiter.nachname,
editLizenz = reiter.lizenz, editLizenz = reiter.lizenz,
@@ -79,6 +81,25 @@ open class ReiterViewModel(initialLoad: Boolean = true) : ViewModel() {
) )
} }
fun addNewReiter() {
uiState = uiState.copy(
selectedReiter = null,
isEditing = true,
editId = "",
editVorname = "",
editName = "",
editLizenz = LizenzKlasse.KEINE,
editSparte = Sparte.KEINE,
editStatus = ReiterStatus.AKTIV,
editFeiId = "",
editOepsNummer = "",
editGeburtsdatum = "",
editEmail = "",
editTelefon = "",
editVerein = ""
)
}
fun onEditFeiIdChange(value: String) { uiState = uiState.copy(editFeiId = value) } fun onEditFeiIdChange(value: String) { uiState = uiState.copy(editFeiId = value) }
fun onEditOepsNummerChange(value: String) { uiState = uiState.copy(editOepsNummer = value) } fun onEditOepsNummerChange(value: String) { uiState = uiState.copy(editOepsNummer = value) }
fun onEditGeburtsdatumChange(value: String) { uiState = uiState.copy(editGeburtsdatum = value) } fun onEditGeburtsdatumChange(value: String) { uiState = uiState.copy(editGeburtsdatum = value) }
@@ -27,6 +27,7 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation(projects.frontend.features.vereinFeature)
implementation(projects.frontend.core.designSystem) implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.network) implementation(projects.frontend.core.network)
implementation(projects.frontend.core.domain) implementation(projects.frontend.core.domain)
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -124,17 +125,26 @@ fun VeranstalterDetailScreen(
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// ── Header mit Zurück-Pfeil ─────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
IconButton(onClick = onZurueck) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Text("Veranstalter-Profil", style = MaterialTheme.typography.headlineSmall)
}
// ── Veranstalter-Header-Card ───────────────────────────────────────── // ── Veranstalter-Header-Card ─────────────────────────────────────────
Surface( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
color = Color.White, colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, Color(0xFFE2E8F0)), border = BorderStroke(1.dp, Color(0xFFE2E8F0)),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(16.dp),
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
@@ -186,12 +196,12 @@ fun VeranstalterDetailScreen(
} }
// Profil bearbeiten // Profil bearbeiten
OutlinedButton( OutlinedButton(
onClick = { /* TODO */ }, onClick = { /* Navigation zu Vereinen */ },
border = BorderStroke(1.dp, Color(0xFFD1D5DB)), border = BorderStroke(1.dp, Color(0xFFD1D5DB)),
) { ) {
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(14.dp)) Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("Profil bearbeiten", fontSize = 13.sp) Text("Bearbeiten", fontSize = 13.sp)
} }
} }
} }
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -13,6 +14,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.components.MsTextField
/** /**
* Formular zum Anlegen eines neuen Veranstalters (Vision_03: Screenshot 21). * Formular zum Anlegen eines neuen Veranstalters (Vision_03: Screenshot 21).
@@ -47,7 +49,15 @@ fun VeranstalterNeuScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
// Header // Header
Column(modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp)) { Row(
modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
IconButton(onClick = onAbbrechen) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Column {
Text( Text(
text = "Neuen Veranstalter anlegen", text = "Neuen Veranstalter anlegen",
fontSize = 22.sp, fontSize = 22.sp,
@@ -60,6 +70,7 @@ fun VeranstalterNeuScreen(
color = Color(0xFF6B7280), color = Color(0xFF6B7280),
) )
} }
}
// Info-Banner // Info-Banner
Surface( Surface(
@@ -110,65 +121,46 @@ fun VeranstalterNeuScreen(
// --- Vereinsdaten --- // --- Vereinsdaten ---
Text("Vereinsdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Text("Vereinsdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField( MsTextField(
value = vereinsname, value = vereinsname,
onValueChange = { vereinsname = it }, onValueChange = { vereinsname = it },
label = { Text("Vereinsname *") }, label = "Vereinsname *",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
Column { MsTextField(
OutlinedTextField(
value = oepsNummer, value = oepsNummer,
onValueChange = { oepsNummer = it }, onValueChange = { oepsNummer = it },
label = { Text("OEPS-Nummer *") }, label = "OEPS-Nummer *",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, helperText = "Offizielle Vereinsnummer des OEPS"
) )
Text(
text = "Offizielle Vereinsnummer des OEPS",
fontSize = 11.sp,
color = Color(0xFF2563EB),
modifier = Modifier.padding(start = 4.dp, top = 2.dp),
)
}
HorizontalDivider() HorizontalDivider()
// --- Kontaktdaten --- // --- Kontaktdaten ---
Text("Kontaktdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Text("Kontaktdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField( MsTextField(
value = ansprechpartner, value = ansprechpartner,
onValueChange = { ansprechpartner = it }, onValueChange = { ansprechpartner = it },
label = { Text("Ansprechpartner *") }, label = "Ansprechpartner *",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
Column { MsTextField(
OutlinedTextField(
value = email, value = email,
onValueChange = { email = it }, onValueChange = { email = it },
label = { Text("E-Mail *") }, label = "E-Mail *",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, helperText = "Login-Daten werden an diese Adresse verschickt"
) )
Text(
text = "Login-Daten werden an diese Adresse verschickt",
fontSize = 11.sp,
color = Color(0xFF6B7280),
modifier = Modifier.padding(start = 4.dp, top = 2.dp),
)
}
OutlinedTextField( MsTextField(
value = telefon, value = telefon,
onValueChange = { telefon = it }, onValueChange = { telefon = it },
label = { Text("Telefon") }, label = "Telefon",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
HorizontalDivider() HorizontalDivider()
@@ -176,28 +168,25 @@ fun VeranstalterNeuScreen(
// --- Adresse --- // --- Adresse ---
Text("Adresse", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Text("Adresse", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField( MsTextField(
value = strasse, value = strasse,
onValueChange = { strasse = it }, onValueChange = { strasse = it },
label = { Text("Straße & Hausnummer") }, label = "Straße & Hausnummer",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField( MsTextField(
value = plz, value = plz,
onValueChange = { plz = it }, onValueChange = { plz = it },
label = { Text("PLZ") }, label = "PLZ",
modifier = Modifier.width(120.dp), modifier = Modifier.width(120.dp),
singleLine = true,
) )
OutlinedTextField( MsTextField(
value = ort, value = ort,
onValueChange = { ort = it }, onValueChange = { ort = it },
label = { Text("Ort") }, label = "Ort",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
singleLine = true,
) )
} }
} }
@@ -2,15 +2,21 @@ package at.mocode.frontend.features.veranstalter.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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.core.designsystem.components.MsDatePickerField
import at.mocode.frontend.core.designsystem.components.MsTextField
/** /**
* Formular zum Anlegen einer neuen Veranstaltung (Titel + Datumspfad). Pflichtfelder: Titel, Datum von/bis. * Formular zum Anlegen einer neuen Veranstaltung (Titel + Datumspfad). Pflichtfelder: Titel, Datum von/bis.
@@ -26,8 +32,9 @@ fun VeranstaltungKonfigScreen(
var datumVon by remember { mutableStateOf("") } var datumVon by remember { mutableStateOf("") }
var datumBis by remember { mutableStateOf("") } var datumBis by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
val datesPresent = datumVon.isNotBlank() && datumBis.isNotBlank() val datesPresent = datumVon.isNotBlank() && datumBis.isNotBlank()
// Einfache Validierung: YYYY-MM-DD Format erzwingen wir hier nicht strikt; wenn beide gesetzt, prüfen wir lexikografisch // Einfache Validierung: YYYY-MM-DD Format erzwingen wir hier nicht strikt; wenn beide gesetzt sind, prüfen wir lexikografisch
val dateOrderOk = !datesPresent || datumBis >= datumVon val dateOrderOk = !datesPresent || datumBis >= datumVon
val valid = titel.isNotBlank() && datesPresent && dateOrderOk val valid = titel.isNotBlank() && datesPresent && dateOrderOk
@@ -61,37 +68,36 @@ fun VeranstaltungKonfigScreen(
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Stammdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Text("Stammdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField( MsTextField(
value = titel, value = titel,
onValueChange = { titel = it }, onValueChange = { titel = it },
label = { Text("Titel *") }, label = "Titel *",
placeholder = "z.B. Frühjahrsturnier 2026",
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
isError = titel.isBlank(), isError = titel.isBlank(),
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
) )
Text("Zeitraum", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Text("Zeitraum", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField( MsDatePickerField(
label = "von *",
value = datumVon, value = datumVon,
onValueChange = { datumVon = it }, onValueChange = { datumVon = it },
label = { Text("von (YYYY-MM-DD) *") },
singleLine = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
isError = datumVon.isBlank(), isError = datumVon.isBlank(),
) )
OutlinedTextField( MsDatePickerField(
label = "bis *",
value = datumBis, value = datumBis,
onValueChange = { datumBis = it }, onValueChange = { datumBis = it },
label = { Text("bis (YYYY-MM-DD) *") },
singleLine = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
isError = datumBis.isBlank() || (datesPresent && !dateOrderOk), isError = datumBis.isBlank() || (datesPresent && !dateOrderOk),
errorMessage = if (datesPresent && !dateOrderOk) "Ungültiger Zeitraum" else null
) )
} }
if (datesPresent && !dateOrderOk) {
Text("Das bis-Datum darf nicht vor dem von-Datum liegen.", color = MaterialTheme.colorScheme.error, fontSize = 12.sp)
}
} }
} }
@@ -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,15 +1,41 @@
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.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.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.graphics.ImageBitmap
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
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalEncodingApi::class)
expect fun decodeBase64ToImage(base64: String): ImageBitmap?
@Composable
expect fun LogoUploadZone(
modifier: Modifier = Modifier,
onFileSelected: (ByteArray) -> Unit
)
@Composable @Composable
fun VereinScreen( fun VereinScreen(
@@ -27,6 +53,26 @@ fun VereinScreen(
) )
}, },
detail = { detail = {
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,
logoBase64 = if (uiState.isEditing) uiState.editLogoBase64 else uiState.selectedVerein?.logoBase64,
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) { if (uiState.isEditing) {
VereinEditorContent( VereinEditorContent(
uiState = uiState, uiState = uiState,
@@ -35,10 +81,16 @@ fun VereinScreen(
onOepsNrChange = viewModel::onEditOepsNrChange, onOepsNrChange = viewModel::onEditOepsNrChange,
onOrtChange = viewModel::onEditOrtChange, onOrtChange = viewModel::onEditOrtChange,
onPlzChange = viewModel::onEditPlzChange, onPlzChange = viewModel::onEditPlzChange,
onStrasseChange = viewModel::onEditStrasseChange,
onHausnummerChange = viewModel::onEditHausnummerChange,
onBundeslandChange = viewModel::onEditBundeslandChange,
onStatusChange = viewModel::onEditStatusChange, onStatusChange = viewModel::onEditStatusChange,
onLogoUrlChange = viewModel::onEditLogoUrlChange,
onLogoFileSelected = viewModel::onLogoFileSelected,
onSave = viewModel::onSave, onSave = viewModel::onSave,
onCancel = viewModel::onCancel onCancel = viewModel::onCancel
) )
}
} else { } else {
PlaceholderContent( PlaceholderContent(
title = "Kein Verein ausgewählt", title = "Kein Verein ausgewählt",
@@ -46,9 +98,154 @@ fun VereinScreen(
) )
} }
} }
}
) )
} }
@Composable
fun VereinCard(
verein: Verein,
onEdit: (() -> Unit)? = null,
onOpenInMaps: () -> Unit = {}
) {
VereinCardPreview(
name = verein.name,
langname = verein.langname,
ort = verein.ort,
plz = verein.plz,
strasse = verein.strasse,
hausnummer = verein.hausnummer,
bundesland = verein.bundesland,
logoUrl = verein.logoUrl,
logoBase64 = verein.logoBase64,
status = verein.status,
onEdit = onEdit
)
}
@Composable
private fun VereinCardPreview(
name: String,
langname: String?,
ort: String?,
plz: String?,
strasse: String?,
hausnummer: String?,
bundesland: String?,
logoUrl: String?,
logoBase64: String?,
status: VereinStatus,
onEdit: (() -> Unit)? = null
) {
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 (!logoBase64.isNullOrBlank()) {
val bitmap = remember(logoBase64) { decodeBase64ToImage(logoBase64) }
if (bitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmap,
contentDescription = "Vereinslogo",
modifier = Modifier.fillMaxSize().clip(CircleShape),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
} else {
Icon(Icons.Default.Image, "Logo Fehler", modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.error)
}
} else if (!logoUrl.isNullOrBlank()) {
Icon(Icons.Default.Business, "Logo URL", modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary)
} else {
Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
}
}
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
)
}
if (onEdit != null) {
MsButton(
text = "Bearbeiten",
onClick = onEdit,
variant = ButtonVariant.OUTLINE,
size = ButtonSize.SMALL
)
}
}
}
}
}
}
@Composable @Composable
private fun VereinListContent( private fun VereinListContent(
uiState: VereinUiState, uiState: VereinUiState,
@@ -77,14 +274,45 @@ private fun VereinListContent(
items = uiState.searchResults, items = uiState.searchResults,
columns = listOf( columns = listOf(
MsColumnDefinition( MsColumnDefinition(
title = "Name", title = "Verein",
weight = 1.5f, weight = 2f,
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) } cellRenderer = {
), Row(
MsColumnDefinition( verticalAlignment = Alignment.CenterVertically,
title = "Ort", horizontalArrangement = Arrangement.spacedBy(12.dp),
weight = 1f, modifier = Modifier.padding(vertical = 4.dp)
cellRenderer = { Text(it.ort ?: "-", style = MaterialTheme.typography.bodySmall) } ) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
if (!it.logoBase64.isNullOrBlank()) {
val bitmap = remember(it.logoBase64) { decodeBase64ToImage(it.logoBase64) }
if (bitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier.fillMaxSize().clip(CircleShape),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
} else {
Icon(Icons.Default.Business, null, modifier = Modifier.size(16.dp))
}
} else {
Icon(Icons.Default.Business, null, modifier = Modifier.size(16.dp))
}
}
Column {
Text(it.name, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold)
if (!it.ort.isNullOrBlank()) {
Text(it.ort, style = MaterialTheme.typography.labelSmall, color = Color.Gray)
}
}
}
}
), ),
MsColumnDefinition( MsColumnDefinition(
title = "OePS-Nr", title = "OePS-Nr",
@@ -116,11 +344,16 @@ 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,
onLogoFileSelected: (ByteArray) -> 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 +362,35 @@ private fun VereinEditorContent(
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
MsTextField( MsTextField(
value = uiState.editName, value = uiState.editName,
onValueChange = onNameChange, onValueChange = onNameChange,
label = "Name (Kurz)", label = "Name (Kurz)",
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
compact = true
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(8.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(),
compact = true
) )
}
// Logo Upload Sektion
LogoUploadZone(
modifier = Modifier
.width(180.dp)
.height(110.dp),
onFileSelected = onLogoFileSelected
)
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -152,7 +399,8 @@ private fun VereinEditorContent(
value = uiState.editOepsNr, value = uiState.editOepsNr,
onValueChange = onOepsNrChange, onValueChange = onOepsNrChange,
label = "OePS-Nr", label = "OePS-Nr",
modifier = Modifier.weight(1f) modifier = Modifier.weight(0.5f),
compact = true
) )
MsEnumDropdown( MsEnumDropdown(
label = "Status", label = "Status",
@@ -160,25 +408,59 @@ private fun VereinEditorContent(
selectedOption = uiState.editStatus, selectedOption = uiState.editStatus,
onOptionSelected = onStatusChange, onOptionSelected = onStatusChange,
optionLabel = { it.label }, optionLabel = { it.label },
modifier = Modifier.weight(1f) modifier = Modifier.weight(0.5f)
) )
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(12.dp))
Text("Adresse", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editStrasse,
onValueChange = onStrasseChange,
label = "Straße",
modifier = Modifier.weight(0.7f),
compact = true
)
MsTextField(
value = uiState.editHausnummer,
onValueChange = onHausnummerChange,
label = "Nr.",
modifier = Modifier.weight(0.3f),
compact = true
)
}
Spacer(Modifier.height(8.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),
compact = true
) )
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),
compact = true
)
MsEnumDropdown(
label = "Bundesland",
options = Bundesland.entries.toTypedArray(),
selectedOption = Bundesland.entries.find { it.label == uiState.editBundesland } ?: Bundesland.WIEN,
onOptionSelected = { onBundeslandChange(it.label) },
optionLabel = { it.label },
modifier = Modifier.weight(0.4f)
) )
} }
Spacer(Modifier.height(32.dp))
} }
} }
@@ -9,6 +9,8 @@ import at.mocode.frontend.features.verein.domain.Verein
import at.mocode.frontend.features.verein.domain.VereinRepository import at.mocode.frontend.features.verein.domain.VereinRepository
import at.mocode.frontend.features.verein.domain.VereinStatus import at.mocode.frontend.features.verein.domain.VereinStatus
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/** /**
* UI-State für die Vereins-Verwaltung. * UI-State für die Vereins-Verwaltung.
@@ -26,7 +28,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 +106,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)
} }
@@ -127,6 +159,18 @@ open class VereinViewModel(
uiState = uiState.copy(editStatus = value) uiState = uiState.copy(editStatus = value)
} }
@OptIn(ExperimentalEncodingApi::class)
fun onLogoFileSelected(bytes: ByteArray) {
println("[VereinViewModel] Logo Datei empfangen, konvertiere zu Base64...")
try {
val base64 = Base64.encode(bytes)
uiState = uiState.copy(editLogoBase64 = base64)
println("[VereinViewModel] Logo erfolgreich in Base64 konvertiert (Länge: ${base64.length})")
} catch (e: Exception) {
println("[VereinViewModel] Fehler bei Base64 Konvertierung: ${e.message}")
}
}
fun onSave() { fun onSave() {
uiState = uiState.copy(isLoading = true, error = null) uiState = uiState.copy(isLoading = true, error = null)
val verein = (uiState.selectedVerein ?: Verein( val verein = (uiState.selectedVerein ?: Verein(
@@ -138,7 +182,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 +218,12 @@ open class VereinViewModel(
editOepsNr = "", editOepsNr = "",
editOrt = "", editOrt = "",
editPlz = "", editPlz = "",
editStatus = VereinStatus.AKTIV editStrasse = "",
editHausnummer = "",
editBundesland = "",
editStatus = VereinStatus.AKTIV,
editLogoUrl = "",
editLogoBase64 = ""
) )
} }
} }
@@ -0,0 +1,101 @@
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.ButtonSize
import at.mocode.frontend.core.designsystem.components.ButtonVariant
import at.mocode.frontend.core.designsystem.components.MsButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.skia.Image
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalEncodingApi::class)
actual fun decodeBase64ToImage(base64: String): ImageBitmap? {
return try {
val bytes = Base64.decode(base64)
Image.makeFromEncoded(bytes).toComposeImageBitmap()
} catch (_: Exception) {
null
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun LogoUploadZone(
modifier: Modifier,
onFileSelected: (ByteArray) -> Unit
) {
val scope = rememberCoroutineScope()
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.Image,
null,
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
modifier = Modifier.size(32.dp)
)
Text("Logo auswählen", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Spacer(Modifier.height(4.dp))
MsButton(
text = "Datei wählen",
onClick = {
scope.launch(Dispatchers.IO) {
try {
println("[LogoUpload] Öffne Datei-Dialog...")
val fileDialog = FileDialog(null as Frame?, "Logo auswählen", FileDialog.LOAD)
fileDialog.isVisible = true
val directory = fileDialog.directory
val file = fileDialog.file
if (directory != null && file != null) {
val selectedFile = File(directory, file)
println("[LogoUpload] Datei ausgewählt: ${selectedFile.absolutePath}")
val bytes = selectedFile.readBytes()
println("[LogoUpload] Bytes gelesen: ${bytes.size}")
onFileSelected(bytes)
} else {
println("[LogoUpload] Auswahl abgebrochen")
}
} catch (e: Exception) {
println("[LogoUpload] FEHLER: ${e.message}")
e.printStackTrace()
}
}
},
variant = ButtonVariant.SECONDARY,
size = ButtonSize.SMALL
)
}
}
}
@@ -0,0 +1,18 @@
package at.mocode.frontend.features.verein.presentation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalEncodingApi::class)
actual fun decodeBase64ToImage(base64: String): ImageBitmap? = null
@Composable
actual fun LogoUploadZone(
modifier: Modifier,
onFileSelected: (ByteArray) -> Unit
) {
// Nicht implementiert für WasmJs
}
@@ -1,9 +1,11 @@
package at.mocode.frontend.features.zns.import.di package at.mocode.frontend.features.zns.import.di
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.features.zns.import.ZnsImportViewModel import at.mocode.frontend.features.zns.import.ZnsImportViewModel
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
val znsImportModule = module { val znsImportModule = module {
factory { ZnsImportViewModel(get(named("apiClient")), get(), get()) } factory { ZnsImportViewModel(get(named("apiClient")), get(), get()) } bind ZnsImportProvider::class
} }
@@ -4,7 +4,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@@ -14,14 +16,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsFilePicker
import at.mocode.frontend.features.zns.import.ZnsImportViewModel import at.mocode.frontend.features.zns.import.ZnsImportViewModel
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import java.io.File import java.io.File
@Composable @Composable
@@ -53,36 +50,40 @@ fun StammdatenImportScreen(
// Datei-Auswahl // Datei-Auswahl
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Datei auswählen", style = MaterialTheme.typography.titleMedium) Text("ZNS-Datei auswählen", style = MaterialTheme.typography.titleMedium)
Row( Text(
verticalAlignment = Alignment.CenterVertically, "Wählen Sie entweder die gesamte ZNS.zip oder eine einzelne .dat Datei (z.B. VEREIN01.dat).",
horizontalArrangement = Arrangement.spacedBy(8.dp), style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.onSurfaceVariant
) { )
OutlinedTextField(
value = state.selectedFilePath ?: "", MsFilePicker(
onValueChange = {}, label = "Pfad zur ZNS-Datei",
readOnly = true, selectedPath = state.selectedFilePath,
placeholder = { Text("ZNS-Datei auswählen (.zip, .dat)...") }, onFileSelected = { viewModel.onFileSelected(it) },
modifier = Modifier.weight(1f), fileExtensions = listOf("zip", "dat"),
singleLine = true, enabled = !state.isUploading && !(!state.isFinished && state.jobId != null)
)
if (state.isUploading || (state.jobId != null && !state.isFinished)) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
LinearProgressIndicator(
progress = { (state.progress / 100f).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer
)
Text(
text = if (state.isUploading) "Datei wird hochgeladen..." else "Import wird verarbeitet... (${state.progress}%)",
style = MaterialTheme.typography.labelSmall
)
if (state.progressDetail.isNotBlank()) {
Text(
text = state.progressDetail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Button(
onClick = {
val chooser = JFileChooser()
chooser.dialogTitle = "ZNS-Datei auswählen"
chooser.fileFilter = FileNameExtensionFilter("ZNS Dateien (*.zip, *.dat)", "zip", "dat")
chooser.isAcceptAllFileFilterUsed = false
val result = chooser.showOpenDialog(null)
if (result == JFileChooser.APPROVE_OPTION) {
viewModel.onFileSelected(chooser.selectedFile.absolutePath)
} }
},
enabled = !state.isUploading && !(!state.isFinished && state.jobId != null),
) {
Icon(Icons.Default.FolderOpen, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Durchsuchen")
} }
} }
@@ -2,5 +2,11 @@
"deviceName": "Meldestelle", "deviceName": "Meldestelle",
"sharedKey": "Password", "sharedKey": "Password",
"backupPath": "/mocode/meldestelle/docs/temp", "backupPath": "/mocode/meldestelle/docs/temp",
"networkRole": "MASTER" "networkRole": "MASTER",
"expectedClients": [
{
"name": "Richter-Turm",
"role": "RICHTER"
}
]
} }
@@ -12,6 +12,7 @@ import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.frontend.features.billing.di.billingModule import at.mocode.frontend.features.billing.di.billingModule
import at.mocode.frontend.features.device.initialization.di.deviceInitializationModule import at.mocode.frontend.features.device.initialization.di.deviceInitializationModule
import at.mocode.frontend.features.funktionaer.di.funktionaerModule
import at.mocode.frontend.features.nennung.di.nennungFeatureModule import at.mocode.frontend.features.nennung.di.nennungFeatureModule
import at.mocode.frontend.features.pferde.di.pferdeModule import at.mocode.frontend.features.pferde.di.pferdeModule
import at.mocode.frontend.features.profile.di.profileModule import at.mocode.frontend.features.profile.di.profileModule
@@ -42,6 +43,7 @@ fun main() = application {
billingModule, billingModule,
pferdeModule, pferdeModule,
reiterModule, reiterModule,
funktionaerModule,
vereinFeatureModule, vereinFeatureModule,
turnierFeatureModule, turnierFeatureModule,
deviceInitializationModule, deviceInitializationModule,
@@ -14,22 +14,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.DpOffset
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
@@ -43,24 +30,41 @@ import at.mocode.frontend.features.device.initialization.data.local.DeviceInitia
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
import at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationScreen import at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationScreen
import at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel import at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel
import at.mocode.frontend.features.funktionaer.presentation.FunktionaerIntent
import at.mocode.frontend.features.funktionaer.presentation.FunktionaerScreen
import at.mocode.frontend.features.funktionaer.presentation.FunktionaerViewModel
import at.mocode.frontend.features.nennung.presentation.NennungManagementScreen 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
@@ -176,12 +180,64 @@ private fun DesktopNavRail(
) )
NavRailItem( NavRailItem(
icon = Icons.Default.People, icon = Icons.Default.CloudDownload,
label = "Vereine", label = "ZNS-Import",
selected = currentScreen is AppScreen.Vereine || currentScreen is AppScreen.VereinVerwaltung, selected = currentScreen is AppScreen.StammdatenImport,
onClick = { onNavigate(AppScreen.Vereine) } onClick = { onNavigate(AppScreen.StammdatenImport) }
) )
var showStammdatenMenu by remember { mutableStateOf(false) }
Box {
NavRailItem(
icon = Icons.Default.Storage,
label = "Stammdaten",
selected = currentScreen is AppScreen.Vereine || currentScreen is AppScreen.VereinVerwaltung ||
currentScreen is AppScreen.Reiter || currentScreen is AppScreen.ReiterVerwaltung ||
currentScreen is AppScreen.Pferde || currentScreen is AppScreen.PferdVerwaltung ||
currentScreen is AppScreen.FunktionaerVerwaltung,
onClick = { showStammdatenMenu = true }
)
DropdownMenu(
expanded = showStammdatenMenu,
onDismissRequest = { showStammdatenMenu = false },
offset = DpOffset(Dimens.NavRailWidth, 0.dp)
) {
DropdownMenuItem(
text = { Text("Vereine") },
onClick = {
showStammdatenMenu = false
onNavigate(AppScreen.Vereine)
},
leadingIcon = { Icon(Icons.Default.People, contentDescription = null) }
)
DropdownMenuItem(
text = { Text("Reiter") },
onClick = {
showStammdatenMenu = false
onNavigate(AppScreen.Reiter)
},
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }
)
DropdownMenuItem(
text = { Text("Pferde") },
onClick = {
showStammdatenMenu = false
onNavigate(AppScreen.Pferde)
},
leadingIcon = { Icon(Icons.Default.Pets, contentDescription = null) }
)
DropdownMenuItem(
text = { Text("Richter") },
onClick = {
showStammdatenMenu = false
onNavigate(AppScreen.FunktionaerVerwaltung)
},
leadingIcon = { Icon(Icons.Default.Gavel, contentDescription = null) }
)
}
}
NavRailItem( NavRailItem(
icon = Icons.Default.Email, icon = Icons.Default.Email,
label = "Mails", label = "Mails",
@@ -552,7 +608,10 @@ private fun DesktopContentArea(
is AppScreen.VeranstaltungVerwaltung -> { is AppScreen.VeranstaltungVerwaltung -> {
VeranstaltungVerwaltung( VeranstaltungVerwaltung(
onVeranstaltungOpen = { vId: Long, eId: Long -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) }, onVeranstaltungOpen = { vId: Long, eId: Long -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) },
onNewVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig()) }, onNewVeranstaltung = {
// Wenn wir direkt aus der Übersicht kommen, erst Veranstalter wählen lassen
onNavigate(AppScreen.VeranstalterAuswahl)
},
onNavigateToPferde = { onNavigate(AppScreen.PferdVerwaltung) }, onNavigateToPferde = { onNavigate(AppScreen.PferdVerwaltung) },
onNavigateToReiter = { onNavigate(AppScreen.ReiterVerwaltung) }, onNavigateToReiter = { onNavigate(AppScreen.ReiterVerwaltung) },
onNavigateToVereine = { onNavigate(AppScreen.VereinVerwaltung) }, onNavigateToVereine = { onNavigate(AppScreen.VereinVerwaltung) },
@@ -570,7 +629,7 @@ private fun DesktopContentArea(
} }
// --- Pferde-Verwaltung & Profil --- // --- Pferde-Verwaltung & Profil ---
is AppScreen.PferdVerwaltung -> { is AppScreen.Pferde, is AppScreen.PferdVerwaltung -> {
val viewModel = koinViewModel<PferdeViewModel>() val viewModel = koinViewModel<PferdeViewModel>()
PferdeScreen(viewModel = viewModel) PferdeScreen(viewModel = viewModel)
} }
@@ -588,7 +647,7 @@ private fun DesktopContentArea(
} }
// --- Reiter-Verwaltung & Profil --- // --- Reiter-Verwaltung & Profil ---
is AppScreen.ReiterVerwaltung -> { is AppScreen.Reiter, is AppScreen.ReiterVerwaltung -> {
val viewModel = koinViewModel<ReiterViewModel>() val viewModel = koinViewModel<ReiterViewModel>()
ReiterScreen(viewModel = viewModel) ReiterScreen(viewModel = viewModel)
} }
@@ -604,7 +663,7 @@ private fun DesktopContentArea(
} }
// --- Verein-Verwaltung & Profil --- // --- Verein-Verwaltung & Profil ---
is AppScreen.VereinVerwaltung -> { is AppScreen.Vereine, is AppScreen.VereinVerwaltung -> {
println("[Screen] Rendering VereinVerwaltung (VereinScreen)") println("[Screen] Rendering VereinVerwaltung (VereinScreen)")
val vereinViewModel: VereinViewModel = koinViewModel() val vereinViewModel: VereinViewModel = koinViewModel()
VereinScreen(viewModel = vereinViewModel) VereinScreen(viewModel = vereinViewModel)
@@ -618,15 +677,20 @@ private fun DesktopContentArea(
} }
// --- Funktionaer-Verwaltung & Profil --- // --- Funktionaer-Verwaltung & Profil ---
is AppScreen.FunktionaerVerwaltung -> FunktionaerVerwaltungScreen( is AppScreen.FunktionaerVerwaltung -> {
onBack = onBack, val viewModel = koinViewModel<FunktionaerViewModel>()
onEdit = { onNavigate(AppScreen.FunktionaerProfil(it)) } FunktionaerScreen(viewModel = viewModel)
) }
is AppScreen.FunktionaerProfil -> FunktionaerProfil( is AppScreen.FunktionaerProfil -> {
id = currentScreen.id, val viewModel = koinViewModel<FunktionaerViewModel>()
onBack = onBack, LaunchedEffect(currentScreen.id) {
) viewModel.state.value.list.find { it.id == currentScreen.id }?.let {
viewModel.send(FunktionaerIntent.Select(it))
}
}
FunktionaerScreen(viewModel = viewModel)
}
// --- Veranstalter-Verwaltung & Profil --- // --- Veranstalter-Verwaltung & Profil ---
is AppScreen.VeranstalterVerwaltung -> VeranstalterVerwaltungScreen( is AppScreen.VeranstalterVerwaltung -> VeranstalterVerwaltungScreen(
@@ -671,12 +735,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))
}
) )
} }
@@ -686,12 +762,12 @@ private fun DesktopContentArea(
if (Store.vereine.none { it.id == vId }) { if (Store.vereine.none { it.id == vId }) {
InvalidContextNotice( InvalidContextNotice(
message = "Veranstalter (ID=$vId) nicht gefunden.", message = "Veranstalter (ID=$vId) nicht gefunden.",
onBack = onBack onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
) )
} else if (Store.eventsFor(vId).none { it.id == evtId }) { } else if (Store.eventsFor(vId).none { it.id == evtId }) {
InvalidContextNotice( InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) gehört nicht zu Veranstalter #$vId.", message = "Veranstaltung (ID=$evtId) gehört nicht zu Veranstalter #$vId.",
onBack = onBack onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) }
) )
} else { } else {
VeranstaltungProfilScreen( VeranstaltungProfilScreen(
@@ -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 = {
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") } Button(onClick = onMarkProcessed) { Text("Als gelesen markieren") }
}
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { Text("Schließen") } TextButton(onClick = onDismiss) { Text("Schließen") }
+1 -1
View File
@@ -73,7 +73,7 @@ dev.port.offset=0
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische # Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische
# Module zu testen. Default=false spart massiv Zeit beim Desktop-Build. # Module zu testen. Default=false spart massiv Zeit beim Desktop-Build.
enableWasm=false enableWasm=true
# Dokka Gradle plugin V2 mode (with helpers for V1 compatibility) # Dokka Gradle plugin V2 mode (with helpers for V1 compatibility)
# See https://kotl.in/dokka-gradle-migration # See https://kotl.in/dokka-gradle-migration