Compare commits
6 Commits
189ebc6565
...
345c329350
| Author | SHA1 | Date | |
|---|---|---|---|
| 345c329350 | |||
| d4aeba4666 | |||
| 9fe889b2c1 | |||
| 85ac1cae9c | |||
| dfaa2e8545 | |||
| bcabb86841 |
|
|
@ -28,6 +28,7 @@ dependencies {
|
|||
// Common service extras
|
||||
implementation(libs.spring.boot.starter.validation)
|
||||
implementation(libs.spring.boot.starter.mail)
|
||||
implementation(libs.spring.boot.starter.actuator)
|
||||
// JSON + Web: ensure Spring Web (incl. HttpMessageConverters) is on the classpath
|
||||
//implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation(libs.spring.boot.starter.web)
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
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.
|
||||
27
docs/99_Journal/2026-04-19_ZnsImportProvider_DI_Fix.md
Normal file
27
docs/99_Journal/2026-04-19_ZnsImportProvider_DI_Fix.md
Normal file
|
|
@ -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.
|
||||
36
docs/99_Journal/2026-04-20_Stammdaten_Sidebar_Refinement.md
Normal file
36
docs/99_Journal/2026-04-20_Stammdaten_Sidebar_Refinement.md
Normal file
|
|
@ -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.*
|
||||
BIN
docs/Neumarkt2026/Neumarkt-Logo.png
Normal file
BIN
docs/Neumarkt2026/Neumarkt-Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -1,12 +1,12 @@
|
|||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
enum class ButtonVariant {
|
||||
|
|
@ -24,6 +24,7 @@ fun MsButton(
|
|||
modifier: Modifier = Modifier,
|
||||
variant: ButtonVariant = ButtonVariant.PRIMARY,
|
||||
size: ButtonSize = ButtonSize.MEDIUM,
|
||||
icon: ImageVector? = null,
|
||||
enabled: Boolean = true,
|
||||
isLoading: Boolean = false,
|
||||
fullWidth: Boolean = false,
|
||||
|
|
@ -44,34 +45,38 @@ fun MsButton(
|
|||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
enabled = enabled && !isLoading,
|
||||
contentPadding = if (icon != null) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.ContentPadding,
|
||||
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(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
enabled = enabled && !isLoading,
|
||||
contentPadding = if (icon != null) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.ContentPadding,
|
||||
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(
|
||||
onClick = onClick,
|
||||
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(
|
||||
onClick = onClick,
|
||||
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,15 +84,27 @@ fun MsButton(
|
|||
@Composable
|
||||
private fun ButtonContent(
|
||||
text: String,
|
||||
isLoading: Boolean
|
||||
isLoading: Boolean,
|
||||
icon: ImageVector? = null
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
strokeWidth = 2.dp
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = LocalContentColor.current
|
||||
)
|
||||
} else {
|
||||
Text(text)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
|
||||
}
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ fun <T> MsDataTable(
|
|||
items: List<T>,
|
||||
columns: List<MsColumnDefinition<T>>,
|
||||
onRowClick: ((T) -> Unit)? = null,
|
||||
selectedItem: T? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
headerBackgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
rowBackgroundColor: Color = MaterialTheme.colorScheme.surface,
|
||||
|
|
@ -100,7 +101,12 @@ fun <T> MsDataTable(
|
|||
val state = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
LazyColumn(state = state, modifier = Modifier.fillMaxSize()) {
|
||||
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(
|
||||
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,
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(40.dp), // Fixe Höhe für High-Density
|
||||
.height(44.dp), // Erhöht von 40.dp auf 44.dp, damit Text nicht abgeschnitten wird
|
||||
placeholder = { Text(searchPlaceholder, style = MaterialTheme.typography.bodySmall) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
trailingIcon = if (searchQuery.isNotEmpty()) {
|
||||
|
|
|
|||
|
|
@ -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.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
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.VisibilityOff
|
||||
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.unit.dp
|
||||
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.model.NetworkRole
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.UIManager
|
||||
|
||||
@Composable
|
||||
actual fun DeviceInitializationConfig(
|
||||
|
|
@ -54,37 +51,28 @@ actual fun DeviceInitializationConfig(
|
|||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("⚙️ Geräte-Konfiguration (${settings.networkRole})", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
MsSettingsField(
|
||||
MsTextField(
|
||||
value = settings.deviceName,
|
||||
onValueChange = { viewModel.updateSettings { s -> s.copy(deviceName = it) } },
|
||||
label = "Gerätename",
|
||||
placeholder = "z.B. Meldestelle-PC-1",
|
||||
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
|
||||
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
errorMessage = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
|
||||
modifier = Modifier.focusRequester(deviceNameFocus).onKeyEvent {
|
||||
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
modifier = Modifier.focusRequester(deviceNameFocus)
|
||||
)
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
MsSettingsField(
|
||||
MsTextField(
|
||||
value = settings.sharedKey,
|
||||
onValueChange = { viewModel.updateSettings { s -> s.copy(sharedKey = it) } },
|
||||
label = "Sicherheitsschlüssel (Sync-Key)",
|
||||
placeholder = "Mindestens 8 Zeichen",
|
||||
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(),
|
||||
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(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Next) },
|
||||
onDone = {
|
||||
|
|
@ -95,58 +83,20 @@ actual fun DeviceInitializationConfig(
|
|||
}
|
||||
}
|
||||
),
|
||||
modifier = Modifier.focusRequester(sharedKeyFocus).onKeyEvent {
|
||||
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||
if (settings.networkRole == NetworkRole.MASTER) {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
} else if (DeviceInitializationValidator.canContinue(settings)) {
|
||||
viewModel.completeInitialization()
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription = if (passwordVisible) "Verbergen" else "Anzeigen"
|
||||
)
|
||||
}
|
||||
}
|
||||
modifier = Modifier.focusRequester(sharedKeyFocus),
|
||||
trailingIcon = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
onTrailingIconClick = { passwordVisible = !passwordVisible }
|
||||
)
|
||||
|
||||
if (settings.networkRole == NetworkRole.MASTER) {
|
||||
OutlinedTextField(
|
||||
value = settings.backupPath,
|
||||
onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } },
|
||||
label = { Text("Backup-Verzeichnis (Pfad)") },
|
||||
placeholder = { Text("/pfad/zu/den/backups") },
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus).onKeyEvent {
|
||||
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||
selectBackupPath(settings.backupPath) { selectedPath ->
|
||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(
|
||||
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")
|
||||
}
|
||||
MsFilePicker(
|
||||
label = "Backup-Verzeichnis (Pfad)",
|
||||
selectedPath = settings.backupPath,
|
||||
onFileSelected = { selectedPath ->
|
||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||
},
|
||||
isError = settings.backupPath.isNotEmpty() && !DeviceInitializationValidator.isBackupPathValid(settings.backupPath)
|
||||
directoryOnly = true,
|
||||
modifier = Modifier.focusRequester(backupPathFocus)
|
||||
)
|
||||
|
||||
Text("Sync-Intervall: ${settings.syncInterval} Min.", style = MaterialTheme.typography.labelMedium)
|
||||
|
|
@ -320,12 +270,12 @@ private fun ClientEntryRow(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
MsTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Gerätename des Clients") },
|
||||
label = "Gerätename des Clients",
|
||||
modifier = Modifier.weight(1f).focusRequester(clientNameFocus),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
imeAction = ImeAction.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
|
||||
|
||||
import at.mocode.frontend.features.funktionaer.domain.Funktionaer
|
||||
import at.mocode.frontend.features.funktionaer.presentation.*
|
||||
import org.koin.dsl.module
|
||||
|
||||
|
|
@ -9,9 +10,9 @@ val funktionaerModule = module {
|
|||
}
|
||||
|
||||
class MockFunktionaerRepository : FunktionaerRepository {
|
||||
override suspend fun list(): List<FunktionaerListItem> = listOf(
|
||||
FunktionaerListItem(1, "Wolfgang Schier", "RICHTER", "G3"),
|
||||
FunktionaerListItem(2, "Alice Schwab", "RICHTER", "INTERNATIONAL"),
|
||||
FunktionaerListItem(3, "Dietmar Gstöttner", "PARCOURSBAUER", null)
|
||||
override suspend fun list(): List<Funktionaer> = listOf(
|
||||
Funktionaer(1, "Wolfgang", "Schier", "12345", listOf("RICHTER"), "G3"),
|
||||
Funktionaer(2, "Alice", "Schwab", "23456", listOf("RICHTER"), "INTERNATIONAL"),
|
||||
Funktionaer(3, "Dietmar", "Gstöttner", "34567", listOf("PARCOURSBAUER"), null)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
package at.mocode.frontend.features.funktionaer.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.components.*
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
import at.mocode.frontend.features.funktionaer.domain.Funktionaer
|
||||
|
||||
@Composable
|
||||
fun FunktionaerScreen(
|
||||
|
|
@ -24,19 +27,31 @@ fun FunktionaerScreen(
|
|||
FunktionaerListContent(
|
||||
state = state,
|
||||
onSearchChange = { viewModel.send(FunktionaerIntent.SearchChanged(it)) },
|
||||
onFunktionaerSelected = { viewModel.send(FunktionaerIntent.Select(it)) }
|
||||
onFunktionaerSelected = { viewModel.send(FunktionaerIntent.Select(it)) },
|
||||
onAddNew = { viewModel.send(FunktionaerIntent.AddNew) }
|
||||
)
|
||||
},
|
||||
detail = {
|
||||
if (state.selectedId != null) {
|
||||
val selected = state.list.find { it.id == state.selectedId }
|
||||
if (selected != null) {
|
||||
FunktionaerDetailContent(selected)
|
||||
}
|
||||
if (state.isEditing) {
|
||||
FunktionaerEditorContent(
|
||||
state = state,
|
||||
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 {
|
||||
PlaceholderContent(
|
||||
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(
|
||||
state: FunktionaerState,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onFunktionaerSelected: (Long) -> Unit
|
||||
onFunktionaerSelected: (Funktionaer) -> Unit,
|
||||
onAddNew: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MsFilterBar(
|
||||
searchQuery = state.searchQuery,
|
||||
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))
|
||||
|
|
@ -68,36 +91,189 @@ private fun FunktionaerListContent(
|
|||
columns = listOf(
|
||||
MsColumnDefinition(
|
||||
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,
|
||||
cellRenderer = { Text(it.name, 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) }
|
||||
cellRenderer = { Text(it.rollen.joinToString(", "), style = MaterialTheme.typography.bodySmall) }
|
||||
)
|
||||
),
|
||||
onRowClick = { onFunktionaerSelected(it.id) }
|
||||
onRowClick = onFunktionaerSelected,
|
||||
selectedItem = state.selectedFunktionaer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FunktionaerDetailContent(item: FunktionaerListItem) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text(item.name, style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Rolle: ${item.rolle}", style = MaterialTheme.typography.bodyLarge)
|
||||
item.lizenz?.let {
|
||||
Text("Lizenz: $it", style = MaterialTheme.typography.bodyLarge)
|
||||
fun FunktionaerCard(
|
||||
funktionaer: Funktionaer,
|
||||
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.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))
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text("Weitere Details folgen in der nächsten Ausbaustufe.", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import at.mocode.frontend.features.funktionaer.domain.Funktionaer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -17,9 +17,15 @@ data class FunktionaerListItem(
|
|||
data class FunktionaerState(
|
||||
val isLoading: Boolean = false,
|
||||
val searchQuery: String = "",
|
||||
val list: List<FunktionaerListItem> = emptyList(),
|
||||
val filtered: List<FunktionaerListItem> = emptyList(),
|
||||
val selectedId: Long? = null,
|
||||
val list: List<Funktionaer> = emptyList(),
|
||||
val filtered: List<Funktionaer> = emptyList(),
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
@ -27,19 +33,25 @@ sealed interface FunktionaerIntent {
|
|||
data object Load : FunktionaerIntent
|
||||
data object Refresh : 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
|
||||
}
|
||||
|
||||
interface FunktionaerRepository {
|
||||
suspend fun list(): List<FunktionaerListItem>
|
||||
suspend fun list(): List<Funktionaer>
|
||||
}
|
||||
|
||||
class FunktionaerViewModel(
|
||||
private val repo: FunktionaerRepository,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(FunktionaerState(isLoading = true))
|
||||
val state: StateFlow<FunktionaerState> = _state
|
||||
|
||||
|
|
@ -49,14 +61,44 @@ class FunktionaerViewModel(
|
|||
when (intent) {
|
||||
is FunktionaerIntent.Load, is FunktionaerIntent.Refresh -> load()
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
reduce { it.copy(isLoading = true, errorMessage = null) }
|
||||
scope.launch {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val items = repo.list()
|
||||
reduce { cur ->
|
||||
|
|
@ -75,13 +117,13 @@ class FunktionaerViewModel(
|
|||
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
|
||||
val q = query.trim()
|
||||
return list.filter {
|
||||
it.name.contains(q, ignoreCase = true) ||
|
||||
it.rolle.contains(q, ignoreCase = true) ||
|
||||
(it.lizenz?.contains(q, ignoreCase = true) ?: false)
|
||||
it.vorname.contains(q, ignoreCase = true) ||
|
||||
it.nachname.contains(q, ignoreCase = true) ||
|
||||
(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> {
|
||||
return try {
|
||||
// Endpunkt müsste im Backend noch implementiert werden, falls gewünscht.
|
||||
// Für jetzt simuliert:
|
||||
client.put("$mailServiceUrl/api/mail/nennungen/$id/status") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("GELESEN")
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
package at.mocode.frontend.features.pferde.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Pets
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.components.*
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
|
|
@ -24,7 +27,8 @@ fun PferdeScreen(
|
|||
PferdeListContent(
|
||||
uiState = uiState,
|
||||
onSearchChange = viewModel::onSearchQueryChange,
|
||||
onPferdSelected = viewModel::selectPferd
|
||||
onPferdSelected = viewModel::selectPferd,
|
||||
onAddNew = { viewModel.addNewPferd() }
|
||||
)
|
||||
},
|
||||
detail = {
|
||||
|
|
@ -43,6 +47,11 @@ fun PferdeScreen(
|
|||
onSave = viewModel::onSave,
|
||||
onCancel = viewModel::onCancel
|
||||
)
|
||||
} else if (uiState.selectedPferd != null) {
|
||||
PferdCard(
|
||||
pferd = uiState.selectedPferd,
|
||||
onEdit = { viewModel.selectPferd(uiState.selectedPferd) }
|
||||
)
|
||||
} else {
|
||||
PlaceholderContent(
|
||||
title = "Kein Pferd ausgewählt",
|
||||
|
|
@ -57,13 +66,21 @@ fun PferdeScreen(
|
|||
private fun PferdeListContent(
|
||||
uiState: PferdeUiState,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onPferdSelected: (Pferd) -> Unit
|
||||
onPferdSelected: (Pferd) -> Unit,
|
||||
onAddNew: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MsFilterBar(
|
||||
searchQuery = uiState.searchQuery,
|
||||
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))
|
||||
|
|
@ -77,9 +94,9 @@ private fun PferdeListContent(
|
|||
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "Lebensnummer",
|
||||
width = 150.dp,
|
||||
cellRenderer = { Text(it.lebensnummer, style = MaterialTheme.typography.bodySmall) }
|
||||
title = "ÖPS-Nr.",
|
||||
width = 100.dp,
|
||||
cellRenderer = { Text(it.oepsNummer ?: "-", style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
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
|
||||
private fun PferdeEditorContent(
|
||||
uiState: PferdeUiState,
|
||||
|
|
@ -127,13 +247,15 @@ private fun PferdeEditorContent(
|
|||
value = uiState.editName,
|
||||
onValueChange = onNameChange,
|
||||
label = "Name",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editLebensnummer,
|
||||
onValueChange = onLebensnummerChange,
|
||||
label = "Lebensnummer",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -144,13 +266,15 @@ private fun PferdeEditorContent(
|
|||
value = uiState.editFeiId,
|
||||
onValueChange = onFeiIdChange,
|
||||
label = "FEI ID",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editOepsNummer,
|
||||
onValueChange = onOepsNummerChange,
|
||||
label = "ÖPS Nummer",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +293,8 @@ private fun PferdeEditorContent(
|
|||
value = uiState.editFarbe,
|
||||
onValueChange = onFarbeChange,
|
||||
label = "Farbe",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -180,13 +305,15 @@ private fun PferdeEditorContent(
|
|||
value = uiState.editGeburtsjahr,
|
||||
onValueChange = onGeburtsjahrChange,
|
||||
label = "Geburtsjahr",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editBesitzer,
|
||||
onValueChange = onBesitzerChange,
|
||||
label = "Besitzer",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ data class PferdeUiState(
|
|||
val selectedPferd: Pferd? = null,
|
||||
val isEditing: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val editId: String = "",
|
||||
val editName: String = "",
|
||||
val editLebensnummer: String = "",
|
||||
val editGeschlecht: Geschlecht = Geschlecht.WALLACH,
|
||||
|
|
@ -59,6 +60,7 @@ open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
|
|||
uiState = uiState.copy(
|
||||
selectedPferd = pferd,
|
||||
isEditing = true,
|
||||
editId = pferd.id,
|
||||
editName = pferd.name,
|
||||
editLebensnummer = pferd.lebensnummer,
|
||||
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) {
|
||||
uiState = uiState.copy(editFeiId = value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package at.mocode.frontend.features.reiter.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.components.*
|
||||
|
|
@ -23,7 +23,8 @@ fun ReiterScreen(
|
|||
ReiterListContent(
|
||||
uiState = uiState,
|
||||
onSearchChange = viewModel::onSearchQueryChange,
|
||||
onReiterSelected = viewModel::selectReiter
|
||||
onReiterSelected = viewModel::selectReiter,
|
||||
onAddNew = { viewModel.addNewReiter() }
|
||||
)
|
||||
},
|
||||
detail = {
|
||||
|
|
@ -43,6 +44,11 @@ fun ReiterScreen(
|
|||
onSave = viewModel::onSave,
|
||||
onCancel = viewModel::onCancel
|
||||
)
|
||||
} else if (uiState.selectedReiter != null) {
|
||||
ReiterCard(
|
||||
reiter = uiState.selectedReiter,
|
||||
onEdit = { viewModel.selectReiter(uiState.selectedReiter) }
|
||||
)
|
||||
} else {
|
||||
PlaceholderContent(
|
||||
title = "Kein Reiter ausgewählt",
|
||||
|
|
@ -57,13 +63,20 @@ fun ReiterScreen(
|
|||
private fun ReiterListContent(
|
||||
uiState: ReiterUiState,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onReiterSelected: (Reiter) -> Unit
|
||||
onReiterSelected: (Reiter) -> Unit,
|
||||
onAddNew: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MsFilterBar(
|
||||
searchQuery = uiState.searchQuery,
|
||||
onSearchQueryChange = onSearchChange,
|
||||
resultCount = uiState.searchResults.size
|
||||
resultCount = uiState.searchResults.size,
|
||||
actions = {
|
||||
MsButton(
|
||||
text = "Reiter anlegen",
|
||||
onClick = onAddNew
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
|
@ -72,14 +85,9 @@ private fun ReiterListContent(
|
|||
items = uiState.searchResults,
|
||||
columns = listOf(
|
||||
MsColumnDefinition(
|
||||
title = "Vorname",
|
||||
weight = 1f,
|
||||
cellRenderer = { Text(it.vorname, style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "Nachname",
|
||||
weight = 1f,
|
||||
cellRenderer = { Text(it.nachname, style = MaterialTheme.typography.bodySmall) }
|
||||
title = "Name",
|
||||
weight = 1.5f,
|
||||
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
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
|
||||
private fun ReiterEditorContent(
|
||||
uiState: ReiterUiState,
|
||||
|
|
@ -133,13 +242,15 @@ private fun ReiterEditorContent(
|
|||
value = uiState.editVorname,
|
||||
onValueChange = onVornameChange,
|
||||
label = "Vorname",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editName,
|
||||
onValueChange = onNachnameChange,
|
||||
label = "Nachname",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -150,13 +261,15 @@ private fun ReiterEditorContent(
|
|||
value = uiState.editFeiId,
|
||||
onValueChange = onFeiIdChange,
|
||||
label = "FEI ID",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editOepsNummer,
|
||||
onValueChange = onOepsNummerChange,
|
||||
label = "ÖPS Nummer",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -167,13 +280,15 @@ private fun ReiterEditorContent(
|
|||
value = uiState.editGeburtsdatum,
|
||||
onValueChange = onGeburtsdatumChange,
|
||||
label = "Geburtsdatum",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editVerein,
|
||||
onValueChange = onVereinChange,
|
||||
label = "Verein",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -184,13 +299,15 @@ private fun ReiterEditorContent(
|
|||
value = uiState.editEmail,
|
||||
onValueChange = onEmailChange,
|
||||
label = "E-Mail",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editTelefon,
|
||||
onValueChange = onTelefonChange,
|
||||
label = "Telefon",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ data class ReiterUiState(
|
|||
val selectedReiter: Reiter? = null,
|
||||
val isEditing: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val editId: String = "",
|
||||
val editName: String = "",
|
||||
val editVorname: String = "",
|
||||
val editLizenz: LizenzKlasse = LizenzKlasse.KEINE,
|
||||
|
|
@ -65,6 +66,7 @@ open class ReiterViewModel(initialLoad: Boolean = true) : ViewModel() {
|
|||
uiState = uiState.copy(
|
||||
selectedReiter = reiter,
|
||||
isEditing = true,
|
||||
editId = reiter.id,
|
||||
editVorname = reiter.vorname,
|
||||
editName = reiter.nachname,
|
||||
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 onEditOepsNummerChange(value: String) { uiState = uiState.copy(editOepsNummer = value) }
|
||||
fun onEditGeburtsdatumChange(value: String) { uiState = uiState.copy(editGeburtsdatum = value) }
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ kotlin {
|
|||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(projects.frontend.features.vereinFeature)
|
||||
implementation(projects.frontend.core.designSystem)
|
||||
implementation(projects.frontend.core.network)
|
||||
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.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -124,17 +125,26 @@ fun VeranstalterDetailScreen(
|
|||
}
|
||||
|
||||
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 ─────────────────────────────────────────
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White,
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
border = BorderStroke(1.dp, Color(0xFFE2E8F0)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
|
|
@ -186,12 +196,12 @@ fun VeranstalterDetailScreen(
|
|||
}
|
||||
// Profil bearbeiten
|
||||
OutlinedButton(
|
||||
onClick = { /* TODO */ },
|
||||
onClick = { /* Navigation zu Vereinen */ },
|
||||
border = BorderStroke(1.dp, Color(0xFFD1D5DB)),
|
||||
) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(14.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.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.*
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.core.designsystem.components.MsTextField
|
||||
|
||||
/**
|
||||
* Formular zum Anlegen eines neuen Veranstalters (Vision_03: Screenshot 21).
|
||||
|
|
@ -47,18 +49,27 @@ fun VeranstalterNeuScreen(
|
|||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Header
|
||||
Column(modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp)) {
|
||||
Text(
|
||||
text = "Neuen Veranstalter anlegen",
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Legen Sie einen neuen Veranstalter (Verein) mit OEPS-Daten an. Nach dem Speichern werden automatisch Login-Daten generiert.",
|
||||
fontSize = 13.sp,
|
||||
color = Color(0xFF6B7280),
|
||||
)
|
||||
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 = "Neuen Veranstalter anlegen",
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Legen Sie einen neuen Veranstalter (Verein) mit OEPS-Daten an. Nach dem Speichern werden automatisch Login-Daten generiert.",
|
||||
fontSize = 13.sp,
|
||||
color = Color(0xFF6B7280),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Info-Banner
|
||||
|
|
@ -110,65 +121,46 @@ fun VeranstalterNeuScreen(
|
|||
// --- Vereinsdaten ---
|
||||
Text("Vereinsdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
||||
|
||||
OutlinedTextField(
|
||||
MsTextField(
|
||||
value = vereinsname,
|
||||
onValueChange = { vereinsname = it },
|
||||
label = { Text("Vereinsname *") },
|
||||
label = "Vereinsname *",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = oepsNummer,
|
||||
onValueChange = { oepsNummer = it },
|
||||
label = { Text("OEPS-Nummer *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Text(
|
||||
text = "Offizielle Vereinsnummer des OEPS",
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF2563EB),
|
||||
modifier = Modifier.padding(start = 4.dp, top = 2.dp),
|
||||
)
|
||||
}
|
||||
MsTextField(
|
||||
value = oepsNummer,
|
||||
onValueChange = { oepsNummer = it },
|
||||
label = "OEPS-Nummer *",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
helperText = "Offizielle Vereinsnummer des OEPS"
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Kontaktdaten ---
|
||||
Text("Kontaktdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
||||
|
||||
OutlinedTextField(
|
||||
MsTextField(
|
||||
value = ansprechpartner,
|
||||
onValueChange = { ansprechpartner = it },
|
||||
label = { Text("Ansprechpartner *") },
|
||||
label = "Ansprechpartner *",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("E-Mail *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Text(
|
||||
text = "Login-Daten werden an diese Adresse verschickt",
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF6B7280),
|
||||
modifier = Modifier.padding(start = 4.dp, top = 2.dp),
|
||||
)
|
||||
}
|
||||
MsTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = "E-Mail *",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
helperText = "Login-Daten werden an diese Adresse verschickt"
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
MsTextField(
|
||||
value = telefon,
|
||||
onValueChange = { telefon = it },
|
||||
label = { Text("Telefon") },
|
||||
label = "Telefon",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
|
@ -176,28 +168,25 @@ fun VeranstalterNeuScreen(
|
|||
// --- Adresse ---
|
||||
Text("Adresse", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
||||
|
||||
OutlinedTextField(
|
||||
MsTextField(
|
||||
value = strasse,
|
||||
onValueChange = { strasse = it },
|
||||
label = { Text("Straße & Hausnummer") },
|
||||
label = "Straße & Hausnummer",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
MsTextField(
|
||||
value = plz,
|
||||
onValueChange = { plz = it },
|
||||
label = { Text("PLZ") },
|
||||
label = "PLZ",
|
||||
modifier = Modifier.width(120.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
MsTextField(
|
||||
value = ort,
|
||||
onValueChange = { ort = it },
|
||||
label = { Text("Ort") },
|
||||
label = "Ort",
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
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.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.
|
||||
|
|
@ -26,8 +32,9 @@ fun VeranstaltungKonfigScreen(
|
|||
var datumVon by remember { mutableStateOf("") }
|
||||
var datumBis by remember { mutableStateOf("") }
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
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 valid = titel.isNotBlank() && datesPresent && dateOrderOk
|
||||
|
||||
|
|
@ -61,37 +68,36 @@ fun VeranstaltungKonfigScreen(
|
|||
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("Stammdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
||||
|
||||
OutlinedTextField(
|
||||
MsTextField(
|
||||
value = titel,
|
||||
onValueChange = { titel = it },
|
||||
label = { Text("Titel *") },
|
||||
label = "Titel *",
|
||||
placeholder = "z.B. Frühjahrsturnier 2026",
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = titel.isBlank(),
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
|
||||
)
|
||||
|
||||
Text("Zeitraum", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
MsDatePickerField(
|
||||
label = "von *",
|
||||
value = datumVon,
|
||||
onValueChange = { datumVon = it },
|
||||
label = { Text("von (YYYY-MM-DD) *") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
isError = datumVon.isBlank(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
MsDatePickerField(
|
||||
label = "bis *",
|
||||
value = datumBis,
|
||||
onValueChange = { datumBis = it },
|
||||
label = { Text("bis (YYYY-MM-DD) *") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
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 ort: String? = null,
|
||||
val plz: String? = null,
|
||||
val strasse: String? = null,
|
||||
val hausnummer: String? = null,
|
||||
val bundesland: String? = null,
|
||||
val land: String = "AUT",
|
||||
val status: VereinStatus = VereinStatus.AKTIV
|
||||
val status: VereinStatus = VereinStatus.AKTIV,
|
||||
val logoUrl: String? = null,
|
||||
val logoBase64: String? = null
|
||||
)
|
||||
|
||||
enum class Bundesland(val label: String) {
|
||||
BURGENLAND("Burgenland"),
|
||||
KAERNTEN("Kärnten"),
|
||||
NIEDEROESTERREICH("Niederösterreich"),
|
||||
OBEROESTERREICH("Oberösterreich"),
|
||||
SALZBURG("Salzburg"),
|
||||
STEIERMARK("Steiermark"),
|
||||
TIROL("Tirol"),
|
||||
VORARLBERG("Vorarlberg"),
|
||||
WIEN("Wien")
|
||||
}
|
||||
|
||||
enum class VereinStatus(val label: String, val color: Color) {
|
||||
AKTIV("Aktiv", Color(0xFF2E7D32)),
|
||||
RUHEND("Ruhend", Color(0xFFE65100)),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,41 @@
|
|||
package at.mocode.frontend.features.verein.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Business
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.components.*
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
import at.mocode.frontend.features.verein.domain.Bundesland
|
||||
import at.mocode.frontend.features.verein.domain.Verein
|
||||
import at.mocode.frontend.features.verein.domain.VereinStatus
|
||||
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
|
||||
fun VereinScreen(
|
||||
|
|
@ -27,28 +53,199 @@ fun VereinScreen(
|
|||
)
|
||||
},
|
||||
detail = {
|
||||
if (uiState.isEditing) {
|
||||
VereinEditorContent(
|
||||
uiState = uiState,
|
||||
onNameChange = viewModel::onEditNameChange,
|
||||
onLangnameChange = viewModel::onEditLangnameChange,
|
||||
onOepsNrChange = viewModel::onEditOepsNrChange,
|
||||
onOrtChange = viewModel::onEditOrtChange,
|
||||
onPlzChange = viewModel::onEditPlzChange,
|
||||
onStatusChange = viewModel::onEditStatusChange,
|
||||
onSave = viewModel::onSave,
|
||||
onCancel = viewModel::onCancel
|
||||
)
|
||||
} else {
|
||||
PlaceholderContent(
|
||||
title = "Kein Verein ausgewählt",
|
||||
subtitle = "Wählen Sie einen Verein aus der Liste aus oder legen Sie einen neuen an."
|
||||
)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (uiState.selectedVerein != null || uiState.isEditing) {
|
||||
// --- Preview Bereich ---
|
||||
VereinCardPreview(
|
||||
name = if (uiState.isEditing) uiState.editName else uiState.selectedVerein?.name ?: "",
|
||||
langname = if (uiState.isEditing) uiState.editLangname else uiState.selectedVerein?.langname,
|
||||
ort = if (uiState.isEditing) uiState.editOrt else uiState.selectedVerein?.ort,
|
||||
plz = if (uiState.isEditing) uiState.editPlz else uiState.selectedVerein?.plz,
|
||||
strasse = if (uiState.isEditing) uiState.editStrasse else uiState.selectedVerein?.strasse,
|
||||
hausnummer = if (uiState.isEditing) uiState.editHausnummer else uiState.selectedVerein?.hausnummer,
|
||||
bundesland = if (uiState.isEditing) uiState.editBundesland else uiState.selectedVerein?.bundesland,
|
||||
logoUrl = if (uiState.isEditing) uiState.editLogoUrl else uiState.selectedVerein?.logoUrl,
|
||||
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) {
|
||||
VereinEditorContent(
|
||||
uiState = uiState,
|
||||
onNameChange = viewModel::onEditNameChange,
|
||||
onLangnameChange = viewModel::onEditLangnameChange,
|
||||
onOepsNrChange = viewModel::onEditOepsNrChange,
|
||||
onOrtChange = viewModel::onEditOrtChange,
|
||||
onPlzChange = viewModel::onEditPlzChange,
|
||||
onStrasseChange = viewModel::onEditStrasseChange,
|
||||
onHausnummerChange = viewModel::onEditHausnummerChange,
|
||||
onBundeslandChange = viewModel::onEditBundeslandChange,
|
||||
onStatusChange = viewModel::onEditStatusChange,
|
||||
onLogoUrlChange = viewModel::onEditLogoUrlChange,
|
||||
onLogoFileSelected = viewModel::onLogoFileSelected,
|
||||
onSave = viewModel::onSave,
|
||||
onCancel = viewModel::onCancel
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PlaceholderContent(
|
||||
title = "Kein Verein ausgewählt",
|
||||
subtitle = "Wählen Sie einen Verein aus der Liste aus oder legen Sie einen neuen an."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
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
|
||||
private fun VereinListContent(
|
||||
uiState: VereinUiState,
|
||||
|
|
@ -77,14 +274,45 @@ private fun VereinListContent(
|
|||
items = uiState.searchResults,
|
||||
columns = listOf(
|
||||
MsColumnDefinition(
|
||||
title = "Name",
|
||||
weight = 1.5f,
|
||||
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "Ort",
|
||||
weight = 1f,
|
||||
cellRenderer = { Text(it.ort ?: "-", style = MaterialTheme.typography.bodySmall) }
|
||||
title = "Verein",
|
||||
weight = 2f,
|
||||
cellRenderer = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
) {
|
||||
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(
|
||||
title = "OePS-Nr",
|
||||
|
|
@ -116,11 +344,16 @@ private fun VereinEditorContent(
|
|||
onOepsNrChange: (String) -> Unit,
|
||||
onOrtChange: (String) -> Unit,
|
||||
onPlzChange: (String) -> Unit,
|
||||
onStrasseChange: (String) -> Unit,
|
||||
onHausnummerChange: (String) -> Unit,
|
||||
onBundeslandChange: (String) -> Unit,
|
||||
onStatusChange: (VereinStatus) -> Unit,
|
||||
onLogoUrlChange: (String) -> Unit,
|
||||
onLogoFileSelected: (ByteArray) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
MsActionToolbar(
|
||||
title = if (uiState.selectedVerein == null) "Neuer Verein" else "Verein Details",
|
||||
onSave = onSave,
|
||||
|
|
@ -129,21 +362,35 @@ private fun VereinEditorContent(
|
|||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
MsTextField(
|
||||
value = uiState.editName,
|
||||
onValueChange = onNameChange,
|
||||
label = "Name (Kurz)",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
MsTextField(
|
||||
value = uiState.editName,
|
||||
onValueChange = onNameChange,
|
||||
label = "Name (Kurz)",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
compact = true
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
MsTextField(
|
||||
value = uiState.editLangname,
|
||||
onValueChange = onLangnameChange,
|
||||
label = "Vollständiger Name",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editLangname,
|
||||
onValueChange = onLangnameChange,
|
||||
label = "Vollständiger Name",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
compact = true
|
||||
)
|
||||
}
|
||||
|
||||
// Logo Upload Sektion
|
||||
LogoUploadZone(
|
||||
modifier = Modifier
|
||||
.width(180.dp)
|
||||
.height(110.dp),
|
||||
onFileSelected = onLogoFileSelected
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
|
|
@ -152,7 +399,8 @@ private fun VereinEditorContent(
|
|||
value = uiState.editOepsNr,
|
||||
onValueChange = onOepsNrChange,
|
||||
label = "OePS-Nr",
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(0.5f),
|
||||
compact = true
|
||||
)
|
||||
MsEnumDropdown(
|
||||
label = "Status",
|
||||
|
|
@ -160,25 +408,59 @@ private fun VereinEditorContent(
|
|||
selectedOption = uiState.editStatus,
|
||||
onOptionSelected = onStatusChange,
|
||||
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)) {
|
||||
MsTextField(
|
||||
value = uiState.editPlz,
|
||||
onValueChange = onPlzChange,
|
||||
label = "PLZ",
|
||||
modifier = Modifier.weight(0.3f)
|
||||
modifier = Modifier.weight(0.2f),
|
||||
compact = true
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editOrt,
|
||||
onValueChange = onOrtChange,
|
||||
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.VereinStatus
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* UI-State für die Vereins-Verwaltung.
|
||||
|
|
@ -26,7 +28,12 @@ data class VereinUiState(
|
|||
val editOepsNr: String = "",
|
||||
val editOrt: String = "",
|
||||
val editPlz: String = "",
|
||||
val editStatus: VereinStatus = VereinStatus.AKTIV
|
||||
val editStrasse: String = "",
|
||||
val editHausnummer: String = "",
|
||||
val editBundesland: String = "",
|
||||
val editStatus: VereinStatus = VereinStatus.AKTIV,
|
||||
val editLogoUrl: String = "",
|
||||
val editLogoBase64: String = ""
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -99,10 +106,35 @@ open class VereinViewModel(
|
|||
editOepsNr = verein.oepsNr ?: "",
|
||||
editOrt = verein.ort ?: "",
|
||||
editPlz = verein.plz ?: "",
|
||||
editStatus = verein.status
|
||||
editStrasse = verein.strasse ?: "",
|
||||
editHausnummer = verein.hausnummer ?: "",
|
||||
editBundesland = verein.bundesland ?: "",
|
||||
editStatus = verein.status,
|
||||
editLogoUrl = verein.logoUrl ?: "",
|
||||
editLogoBase64 = verein.logoBase64 ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
fun onEditStrasseChange(value: String) {
|
||||
uiState = uiState.copy(editStrasse = value)
|
||||
}
|
||||
|
||||
fun onEditHausnummerChange(value: String) {
|
||||
uiState = uiState.copy(editHausnummer = value)
|
||||
}
|
||||
|
||||
fun onEditBundeslandChange(value: String) {
|
||||
uiState = uiState.copy(editBundesland = value)
|
||||
}
|
||||
|
||||
fun onEditLogoBase64Change(value: String) {
|
||||
uiState = uiState.copy(editLogoBase64 = value)
|
||||
}
|
||||
|
||||
fun onEditLogoUrlChange(value: String) {
|
||||
uiState = uiState.copy(editLogoUrl = value)
|
||||
}
|
||||
|
||||
fun onEditNameChange(value: String) {
|
||||
uiState = uiState.copy(editName = value)
|
||||
}
|
||||
|
|
@ -127,6 +159,18 @@ open class VereinViewModel(
|
|||
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() {
|
||||
uiState = uiState.copy(isLoading = true, error = null)
|
||||
val verein = (uiState.selectedVerein ?: Verein(
|
||||
|
|
@ -138,7 +182,12 @@ open class VereinViewModel(
|
|||
oepsNr = uiState.editOepsNr,
|
||||
ort = uiState.editOrt,
|
||||
plz = uiState.editPlz,
|
||||
status = uiState.editStatus
|
||||
strasse = uiState.editStrasse,
|
||||
hausnummer = uiState.editHausnummer,
|
||||
bundesland = uiState.editBundesland,
|
||||
status = uiState.editStatus,
|
||||
logoUrl = uiState.editLogoUrl.ifBlank { null },
|
||||
logoBase64 = uiState.editLogoBase64.ifBlank { null }
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
|
|
@ -169,7 +218,12 @@ open class VereinViewModel(
|
|||
editOepsNr = "",
|
||||
editOrt = "",
|
||||
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
|
||||
|
||||
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||
import at.mocode.frontend.features.zns.import.ZnsImportViewModel
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
|
|
@ -14,14 +16,9 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.components.MsFilePicker
|
||||
import at.mocode.frontend.features.zns.import.ZnsImportViewModel
|
||||
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
|
||||
|
||||
@Composable
|
||||
|
|
@ -53,36 +50,40 @@ fun StammdatenImportScreen(
|
|||
// Datei-Auswahl
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Datei auswählen", style = MaterialTheme.typography.titleMedium)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.selectedFilePath ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
placeholder = { Text("ZNS-Datei auswählen (.zip, .dat)...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
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")
|
||||
Text("ZNS-Datei auswählen", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Wählen Sie entweder die gesamte ZNS.zip oder eine einzelne .dat Datei (z.B. VEREIN01.dat).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
MsFilePicker(
|
||||
label = "Pfad zur ZNS-Datei",
|
||||
selectedPath = state.selectedFilePath,
|
||||
onFileSelected = { viewModel.onFileSelected(it) },
|
||||
fileExtensions = listOf("zip", "dat"),
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,11 @@
|
|||
"deviceName": "Meldestelle",
|
||||
"sharedKey": "Password",
|
||||
"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.features.billing.di.billingModule
|
||||
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.pferde.di.pferdeModule
|
||||
import at.mocode.frontend.features.profile.di.profileModule
|
||||
|
|
@ -42,6 +43,7 @@ fun main() = application {
|
|||
billingModule,
|
||||
pferdeModule,
|
||||
reiterModule,
|
||||
funktionaerModule,
|
||||
vereinFeatureModule,
|
||||
turnierFeatureModule,
|
||||
deviceInitializationModule,
|
||||
|
|
|
|||
|
|
@ -14,22 +14,9 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.shell.desktop.data.Store
|
||||
import at.mocode.frontend.shell.desktop.data.Turnier
|
||||
import at.mocode.frontend.shell.desktop.data.TurnierStore
|
||||
import at.mocode.frontend.shell.desktop.screens.management.FunktionaerVerwaltungScreen
|
||||
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterAuswahl
|
||||
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterDetail
|
||||
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterVerwaltungScreen
|
||||
import at.mocode.frontend.shell.desktop.screens.nennung.NennungsEingangScreen
|
||||
import at.mocode.frontend.shell.desktop.screens.profile.FunktionaerProfil
|
||||
import at.mocode.frontend.shell.desktop.screens.veranstaltung.VeranstaltungVerwaltung
|
||||
import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungKonfig
|
||||
import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen
|
||||
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.TurnierWizard
|
||||
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.VeranstalterAnlegenWizard
|
||||
import at.mocode.frontend.core.auth.data.local.AuthTokenManager
|
||||
import at.mocode.frontend.core.designsystem.theme.AppColors
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
|
|
@ -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.presentation.DeviceInitializationScreen
|
||||
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.NennungViewModel
|
||||
import at.mocode.frontend.features.pferde.presentation.PferdeScreen
|
||||
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
|
||||
import at.mocode.frontend.features.ping.presentation.PingScreen
|
||||
import at.mocode.frontend.features.ping.presentation.PingViewModel
|
||||
import at.mocode.frontend.features.profile.presentation.ProfileScreen
|
||||
import at.mocode.frontend.features.profile.presentation.ProfileViewModel
|
||||
import at.mocode.frontend.features.reiter.presentation.ReiterScreen
|
||||
import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
|
||||
import at.mocode.frontend.features.verein.presentation.VereinScreen
|
||||
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
||||
import at.mocode.frontend.features.ping.presentation.PingScreen
|
||||
import at.mocode.frontend.features.ping.presentation.PingViewModel
|
||||
import at.mocode.frontend.features.turnier.presentation.SeriesScreen
|
||||
import at.mocode.frontend.features.turnier.presentation.TurnierDetailScreen
|
||||
import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfigScreen
|
||||
import at.mocode.frontend.features.verein.presentation.VereinScreen
|
||||
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
||||
import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScreen
|
||||
import at.mocode.frontend.shell.desktop.data.Store
|
||||
import at.mocode.frontend.shell.desktop.data.Turnier
|
||||
import at.mocode.frontend.shell.desktop.data.TurnierStore
|
||||
import at.mocode.frontend.shell.desktop.screens.management.FunktionaerVerwaltungScreen
|
||||
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterAuswahl
|
||||
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterDetail
|
||||
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterVerwaltungScreen
|
||||
import at.mocode.frontend.shell.desktop.screens.nennung.NennungsEingangScreen
|
||||
import at.mocode.frontend.shell.desktop.screens.profile.FunktionaerProfil
|
||||
import at.mocode.frontend.shell.desktop.screens.veranstaltung.VeranstaltungVerwaltung
|
||||
import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen
|
||||
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.TurnierWizard
|
||||
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.VeranstalterAnlegenWizard
|
||||
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
|
||||
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
|
||||
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
|
||||
import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScreen
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
|
@ -176,12 +180,64 @@ private fun DesktopNavRail(
|
|||
)
|
||||
|
||||
NavRailItem(
|
||||
icon = Icons.Default.People,
|
||||
label = "Vereine",
|
||||
selected = currentScreen is AppScreen.Vereine || currentScreen is AppScreen.VereinVerwaltung,
|
||||
onClick = { onNavigate(AppScreen.Vereine) }
|
||||
icon = Icons.Default.CloudDownload,
|
||||
label = "ZNS-Import",
|
||||
selected = currentScreen is AppScreen.StammdatenImport,
|
||||
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(
|
||||
icon = Icons.Default.Email,
|
||||
label = "Mails",
|
||||
|
|
@ -552,7 +608,10 @@ private fun DesktopContentArea(
|
|||
is AppScreen.VeranstaltungVerwaltung -> {
|
||||
VeranstaltungVerwaltung(
|
||||
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) },
|
||||
onNavigateToReiter = { onNavigate(AppScreen.ReiterVerwaltung) },
|
||||
onNavigateToVereine = { onNavigate(AppScreen.VereinVerwaltung) },
|
||||
|
|
@ -570,7 +629,7 @@ private fun DesktopContentArea(
|
|||
}
|
||||
|
||||
// --- Pferde-Verwaltung & Profil ---
|
||||
is AppScreen.PferdVerwaltung -> {
|
||||
is AppScreen.Pferde, is AppScreen.PferdVerwaltung -> {
|
||||
val viewModel = koinViewModel<PferdeViewModel>()
|
||||
PferdeScreen(viewModel = viewModel)
|
||||
}
|
||||
|
|
@ -588,7 +647,7 @@ private fun DesktopContentArea(
|
|||
}
|
||||
|
||||
// --- Reiter-Verwaltung & Profil ---
|
||||
is AppScreen.ReiterVerwaltung -> {
|
||||
is AppScreen.Reiter, is AppScreen.ReiterVerwaltung -> {
|
||||
val viewModel = koinViewModel<ReiterViewModel>()
|
||||
ReiterScreen(viewModel = viewModel)
|
||||
}
|
||||
|
|
@ -604,7 +663,7 @@ private fun DesktopContentArea(
|
|||
}
|
||||
|
||||
// --- Verein-Verwaltung & Profil ---
|
||||
is AppScreen.VereinVerwaltung -> {
|
||||
is AppScreen.Vereine, is AppScreen.VereinVerwaltung -> {
|
||||
println("[Screen] Rendering VereinVerwaltung (VereinScreen)")
|
||||
val vereinViewModel: VereinViewModel = koinViewModel()
|
||||
VereinScreen(viewModel = vereinViewModel)
|
||||
|
|
@ -618,15 +677,20 @@ private fun DesktopContentArea(
|
|||
}
|
||||
|
||||
// --- Funktionaer-Verwaltung & Profil ---
|
||||
is AppScreen.FunktionaerVerwaltung -> FunktionaerVerwaltungScreen(
|
||||
onBack = onBack,
|
||||
onEdit = { onNavigate(AppScreen.FunktionaerProfil(it)) }
|
||||
)
|
||||
is AppScreen.FunktionaerVerwaltung -> {
|
||||
val viewModel = koinViewModel<FunktionaerViewModel>()
|
||||
FunktionaerScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
is AppScreen.FunktionaerProfil -> FunktionaerProfil(
|
||||
id = currentScreen.id,
|
||||
onBack = onBack,
|
||||
)
|
||||
is AppScreen.FunktionaerProfil -> {
|
||||
val viewModel = koinViewModel<FunktionaerViewModel>()
|
||||
LaunchedEffect(currentScreen.id) {
|
||||
viewModel.state.value.list.find { it.id == currentScreen.id }?.let {
|
||||
viewModel.send(FunktionaerIntent.Select(it))
|
||||
}
|
||||
}
|
||||
FunktionaerScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// --- Veranstalter-Verwaltung & Profil ---
|
||||
is AppScreen.VeranstalterVerwaltung -> VeranstalterVerwaltungScreen(
|
||||
|
|
@ -671,12 +735,24 @@ private fun DesktopContentArea(
|
|||
|
||||
is AppScreen.VeranstaltungKonfig -> {
|
||||
val vId = currentScreen.veranstalterId
|
||||
// Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard
|
||||
VeranstaltungKonfig(
|
||||
VeranstaltungKonfigScreen(
|
||||
veranstalterId = vId,
|
||||
onBack = onBack,
|
||||
onSaved = { evtId: Long, finalVId: Long -> onNavigate(AppScreen.VeranstaltungProfil(finalVId, evtId)) },
|
||||
onVeranstalterCreated = { newVId: Long -> onNavigate(AppScreen.VeranstalterDetail(newVId)) }
|
||||
onAbbrechen = onBack,
|
||||
onSpeichern = { titel, datumVon, datumBis ->
|
||||
// In-Memory Store Simulation
|
||||
val allEvents = Store.allEvents()
|
||||
val newId = (allEvents.maxOfOrNull { it.id } ?: 0L) + 1L
|
||||
val newEvent = at.mocode.frontend.shell.desktop.data.Veranstaltung(
|
||||
id = newId,
|
||||
veranstalterId = vId,
|
||||
titel = titel,
|
||||
datumVon = datumVon,
|
||||
datumBis = datumBis,
|
||||
status = "NEU"
|
||||
)
|
||||
Store.addEventFirst(vId, newEvent)
|
||||
onNavigate(AppScreen.VeranstaltungProfil(vId, newId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -686,12 +762,12 @@ private fun DesktopContentArea(
|
|||
if (Store.vereine.none { it.id == vId }) {
|
||||
InvalidContextNotice(
|
||||
message = "Veranstalter (ID=$vId) nicht gefunden.",
|
||||
onBack = onBack
|
||||
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
|
||||
)
|
||||
} else if (Store.eventsFor(vId).none { it.id == evtId }) {
|
||||
InvalidContextNotice(
|
||||
message = "Veranstaltung (ID=$evtId) gehört nicht zu Veranstalter #$vId.",
|
||||
onBack = onBack
|
||||
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) }
|
||||
)
|
||||
} else {
|
||||
VeranstaltungProfilScreen(
|
||||
|
|
|
|||
|
|
@ -83,13 +83,15 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
|
|||
}
|
||||
|
||||
val filteredMails = remember(mails, searchQuery) {
|
||||
if (searchQuery.isBlank()) mails
|
||||
val base = if (searchQuery.isBlank()) mails
|
||||
else mails.filter {
|
||||
it.vorname.contains(searchQuery, ignoreCase = true) ||
|
||||
it.nachname.contains(searchQuery, ignoreCase = true) ||
|
||||
it.pferd.contains(searchQuery, ignoreCase = true) ||
|
||||
it.turnierNr.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
// Standard-Sortierung: Neueste zuerst (Status NEU oben, dann nach TurnierNr)
|
||||
base.sortedWith(compareBy({ it.status != "NEU" }, { it.turnierNr }, { it.datum }))
|
||||
}
|
||||
|
||||
// Initiales Laden
|
||||
|
|
@ -108,6 +110,21 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
|
|||
mails = updated
|
||||
selectedMail = null
|
||||
}
|
||||
},
|
||||
onSendReply = {
|
||||
scope.launch {
|
||||
repository.sendeAntwort(
|
||||
email = selectedMail!!.sender,
|
||||
turnierNr = selectedMail!!.turnierNr,
|
||||
vorname = selectedMail!!.vorname,
|
||||
nachname = selectedMail!!.nachname
|
||||
)
|
||||
// Nach Antwort automatisch als gelesen markieren
|
||||
repository.markiereAlsGelesen(selectedMail!!.id)
|
||||
val updated = mails.map { if (it.id == selectedMail!!.id) it.copy(status = "GELESEN") else it }
|
||||
mails = updated
|
||||
selectedMail = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -212,7 +229,12 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkProcessed: () -> Unit) {
|
||||
fun NennungDetailDialog(
|
||||
mail: OnlineNennungMail,
|
||||
onDismiss: () -> Unit,
|
||||
onMarkProcessed: () -> Unit,
|
||||
onSendReply: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Details zur Online-EntryManagement") },
|
||||
|
|
@ -235,7 +257,19 @@ fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkPr
|
|||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onMarkProcessed) { Text("Als gelesen markieren") }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (mail.status == "NEU") {
|
||||
Button(
|
||||
onClick = onSendReply,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32))
|
||||
) {
|
||||
Icon(Icons.Default.Email, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Antwort & Übernahme")
|
||||
}
|
||||
}
|
||||
Button(onClick = onMarkProcessed) { Text("Als gelesen markieren") }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Schließen") }
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ dev.port.offset=0
|
|||
# ------------------------------------------------------------------
|
||||
# Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische
|
||||
# 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)
|
||||
# See https://kotl.in/dokka-gradle-migration
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user