Compare commits

...

6 Commits

Author SHA1 Message Date
345c329350 chore: enhance Stammdaten-Verwaltung and refine desktop UX across multiple features, fix typo in settings.json, enable WASM builds, and add Master-Detail layout for Funktionäre
Some checks failed
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
2026-04-20 02:49:34 +02:00
d4aeba4666 chore: implementiere MsFilePicker-Komponente, ersetze veraltete Input-Felder in Geräteneukonfiguration und ZNS-Importer, verbessere Vereinskarten-Darstellung und Detail-UX, behebe Tippfehler in settings.json 2026-04-20 02:00:36 +02:00
9fe889b2c1 chore: bereinige unbenutzte Importe in VereinScreens.kt und LogoUploadZone.jvm.kt, verbessere Code-Readability durch konsistenten Importstil 2026-04-20 01:21:18 +02:00
85ac1cae9c chore: implementiere Logo-Upload-Zone mit Base64-Unterstützung, verbessere Vereinsverwaltung mit kompakten Feldern und nutzerspezifischen Uploadoptionen, optimiere Desktop-UX und Navigation 2026-04-20 01:20:20 +02:00
dfaa2e8545 chore: consolidate redundant controllers in mail-service, improve backend stability, refine desktop UX, and enhance Vereinsverwaltung functionality 2026-04-20 00:21:20 +02:00
bcabb86841 chore: fix DI binding for ZnsImportProvider in zns-import-feature, ensure proper Koin resolution 2026-04-19 22:18:18 +02:00
41 changed files with 2023 additions and 485 deletions

View File

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

View File

@ -1,34 +0,0 @@
@file:OptIn(ExperimentalUuidApi::class)
package at.mocode.mail.service
import at.mocode.mail.service.persistence.NennungEntity
import at.mocode.mail.service.persistence.NennungRepository
import org.springframework.web.bind.annotation.*
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@RestController
@RequestMapping("/api/mail/nennungen")
class NennungController(
private val nennungRepository: NennungRepository
) {
@GetMapping
fun getAllNennungen(): List<NennungEntity> {
return nennungRepository.findAll()
}
@PutMapping("/{id}/status")
fun updateStatus(
@PathVariable id: String,
@RequestBody newStatus: String
) {
nennungRepository.updateStatus(Uuid.parse(id), newStatus)
}
@PostMapping
fun createNennung(@RequestBody nennung: NennungEntity) {
nennungRepository.save(nennung)
}
}

View File

@ -111,4 +111,49 @@ class MailController(
fun getAllNennungen(): List<NennungEntity> {
return nennungRepository.findAll()
}
@PutMapping("/nennungen/{id}/status")
fun updateStatus(
@PathVariable id: String,
@RequestBody newStatus: String
) {
nennungRepository.updateStatus(Uuid.parse(id), newStatus)
}
@PostMapping("/nennungen")
fun createNennung(@RequestBody nennung: NennungEntity) {
nennungRepository.save(nennung)
}
@PostMapping("/send-reply")
fun sendReply(
@RequestParam email: String,
@RequestParam turnierNr: String,
@RequestParam vorname: String,
@RequestParam nachname: String
) {
val message = SimpleMailMessage()
val dynamicFrom = try {
val (user, domain) = baseMailAddress.split("@")
"$user+$turnierNr@$domain"
} catch (_: Exception) {
baseMailAddress
}
message.from = dynamicFrom
message.setTo(email)
message.subject = "Bestätigung: Nennung für Turnier $turnierNr manuell übernommen"
message.text = """
Sehr geehrte(r) $vorname $nachname,
Ihre Online-Nennung für das Turnier $turnierNr wurde von uns manuell in das Turniersystem übernommen.
Viel Erfolg beim Turnier!
Mit freundlichen Grüßen,
Ihre Meldestelle
""".trimIndent()
mailSender.send(message)
logger.info("Antwort-Mail an $email gesendet.")
}
}

View File

@ -0,0 +1,28 @@
# Journal: 19. April 2026 - Backend Stabilität & Desktop UX-Refinement
## 🏗️ Backend: Infrastruktur & Mail-Service
* **Mail-Service:** Konflikt beim Request-Mapping behoben. Der redundante `NennungController` wurde entfernt und seine Funktionalität (Status-Update, Erstellung) in den zentralen `MailController` integriert.
* **Health-Checks:** `spring-boot-starter-actuator` zum `entries-service` hinzugefügt, um die 404-Fehler in der Consul-Überwachung zu eliminieren.
* **Mail-Features:** Neuer Endpunkt `POST /send-reply` im `MailController` implementiert, um Bestätigungs-Mails an Nenner mit dynamischer Absenderadresse (Turnier-spezifisch) zu senden.
## 💻 Desktop-App: Navigation & UI
* **Veranstaltungs-Konfiguration:** White-Screen Fix durch Korrektur der Navigation im `DesktopMainLayout.kt`. Es wird nun korrekt auf den `VeranstaltungKonfigScreen` aus dem Feature-Modul verwiesen.
* **Device-Setup:** UX-Verbesserung durch Entfernung blockierender `onKeyEvent` Handler. Die Navigation zwischen Feldern mittels **Tab** und **Enter** funktioniert nun reibungslos über den Standard-Fokus-Flow.
* **Design-System:**
* Suchfeld-Höhe in `MsFilterBar.kt` auf `44.dp` erhöht, um abgeschnittenen Text bei kleinen Schriftarten zu verhindern.
* `MsMasterDetailLayout` im Vereins-Bereich um einen **Preview-Bereich** (Card-Ansicht) erweitert.
## 🚀 Neue Features
### Nennungs-Eingang
* **Antwort-Funktion:** Ein neuer Button "Antwort & Übernahme" im Detail-Dialog ermöglicht das direkte Versenden einer Bestätigungs-Mail an den Nenner.
* **Sortierung:** Die Liste wird nun standardmäßig mit neuen Nennungen (`NEU`) zuerst sortiert.
### Vereins-Verwaltung
* **Card-Preview:** Der obere Teil des Detail-Bereichs zeigt nun eine visuelle Vorschau des Vereins (Name, Status, Ort).
* **Logo-Support:** Das Domain-Modell und der Editor wurden um ein `logoUrl` Feld erweitert, um Vereinslogos (z.B. für nicht registrierte Vereine) zu hinterlegen.
## 🧹 Curator Hinweis
Alle gemeldeten Start-Fehler im Backend wurden behoben. Die Desktop-App ist nun voll navigierbar und bietet verbesserte Effizienz für die Meldestellen-Mitarbeiter.

View File

@ -0,0 +1,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.

View File

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

View 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).

View File

@ -0,0 +1,53 @@
# Journal-Eintrag: Vereins-Verwaltung Erweiterung (Logo & Adresse)
**Datum:** 20. April 2026
**Status:** Abgeschlossen (Bugfix & Feature-Integration)
**Beteiligte Agenten:** 🏗️ [Lead Architect], 🎨 [Frontend Expert], 🧹 [Curator], 🧐 [QA Specialist]
## 📝 Zusammenfassung
Die Vereins-Verwaltung wurde um detaillierte Adressdaten und ein funktionales Logo-Management erweitert. Ein kritischer Bug, der zum Einfrieren der App beim Datei-Import führte, wurde behoben. Logos werden nun in der Vorschau korrekt gerendert.
## 🛠️ Technische Änderungen
### 0. Bugfix: Logo-Picker UI-Freeze
* **Problem:** Der `FileDialog` (AWT) blockierte den Main-Thread, was zum Einfrieren der App führte.
* **Lösung:** Auslagerung des Dialog-Aufrufs in einen asynchronen `Dispatchers.IO` Kontext in `LogoUploadZone.jvm.kt`.
* **Stabilität:** Integration von Try-Catch Blöcken und detailliertem Logging für den Datei-Import-Prozess.
### 1. Feature: Logo-Rendering (Base64)
* **Implementation:** Einführung einer `expect/actual` Funktion `decodeBase64ToImage`.
* **JVM-Logic:** Nutzung von `org.jetbrains.skia.Image` zur Dekodierung der Base64-Bytes in eine `ImageBitmap`.
* **UI-Integration:** Die `VereinCardPreview` rendert nun das Vereinslogo direkt aus dem gespeicherten Base64-String mittels `androidx.compose.foundation.Image`.
### 2. Domain-Modell (`Verein.kt`)
* Erweiterung um Felder: `strasse`, `hausnummer`, `bundesland` (Enum).
* Neues Feld `logoBase64` für die Offline-Speicherung von optimierten Vereinslogos.
* Einführung des Enums `Bundesland` mit den 9 österreichischen Bundesländern zur Sicherstellung der Datenqualität (ÖTO-konform).
### 2. ViewModel (`VereinViewModel.kt`)
* Erweiterung des `VereinUiState` um die neuen Adress- und Logo-Felder.
* Implementierung der Change-Handler für alle neuen Felder.
* Anpassung der `onSave`- und `onAddNew`-Methoden zur Berücksichtigung der erweiterten Datenstruktur.
### 3. UI-Anpassungen (`VereinScreens.kt`)
* **Card-Preview:**
* Anzeige der vollständigen Adresse (Straße, Hausnummer, PLZ, Ort, Bundesland).
* Integration eines "Maps"-Buttons, der die Adresse direkt in Google Maps öffnet (via `LocalUriHandler`).
* Vergrößertes Logo-Display (80dp) mit modernem Design.
* **Editor:**
* Logische Gruppierung der Adressfelder (Straße/Nr. in einer Zeile, PLZ/Ort/Bundesland in der nächsten).
* Einsatz des `MsEnumDropdown` für die Bundesland-Auswahl.
* Vorbereitung einer "Logo-Upload-Zone" mit visuellem Feedback für Drag-and-Drop / FilePicker.
## 🔍 Verifikation
* [x] Bugfix: Datei-Dialog friert die UI nicht mehr ein (IO-Dispatcher).
* [x] Feature: Base64-Logo wird in der Card-Vorschau gerendert.
* [x] Feature: Logging im ViewModel und Logo-Service implementiert.
* [x] UI: Kompakte Adressfelder und Google-Maps-Link funktionieren.
## 📌 Nächste Schritte
* Implementierung einer tatsächlichen Bild-Skalierung vor dem Base64-Encoding, um Datenbank-Größe zu optimieren.
* Finalisierung der Drag-and-Drop Logik (`onExternalDrag`), sobald Bibliotheks-Support stabil ist.
---
*Dokumentiert durch den Curator.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -51,7 +51,7 @@ fun MsFilterBar(
onValueChange = onSearchQueryChange,
modifier = Modifier
.width(300.dp)
.height(40.dp), // Fixe Höhe für High-Density
.height(44.dp), // Erhöht von 40.dp auf 44.dp, damit Text nicht abgeschnitten wird
placeholder = { Text(searchPlaceholder, style = MaterialTheme.typography.bodySmall) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
trailingIcon = if (searchQuery.isNotEmpty()) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -50,10 +50,26 @@ class NennungRemoteRepository(private val client: HttpClient) {
}
}
suspend fun sendeAntwort(email: String, turnierNr: String, vorname: String, nachname: String): Result<Unit> {
return try {
client.post("$mailServiceUrl/api/mail/send-reply") {
parameter("email", email)
parameter("turnierNr", turnierNr)
parameter("vorname", vorname)
parameter("nachname", nachname)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun markiereAlsGelesen(id: String): Result<Unit> {
return try {
// Endpunkt müsste im Backend noch implementiert werden, falls gewünscht.
// Für jetzt simuliert:
client.put("$mailServiceUrl/api/mail/nennungen/$id/status") {
contentType(ContentType.Application.Json)
setBody("GELESEN")
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,10 +12,27 @@ data class Verein(
val oepsNr: String? = null,
val ort: String? = null,
val plz: String? = null,
val strasse: String? = null,
val hausnummer: String? = null,
val bundesland: String? = null,
val land: String = "AUT",
val status: VereinStatus = VereinStatus.AKTIV
val status: VereinStatus = VereinStatus.AKTIV,
val logoUrl: String? = null,
val logoBase64: String? = null
)
enum class Bundesland(val label: String) {
BURGENLAND("Burgenland"),
KAERNTEN("Kärnten"),
NIEDEROESTERREICH("Niederösterreich"),
OBEROESTERREICH("Oberösterreich"),
SALZBURG("Salzburg"),
STEIERMARK("Steiermark"),
TIROL("Tirol"),
VORARLBERG("Vorarlberg"),
WIEN("Wien")
}
enum class VereinStatus(val label: String, val color: Color) {
AKTIV("Aktiv", Color(0xFF2E7D32)),
RUHEND("Ruhend", Color(0xFFE65100)),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,5 +2,11 @@
"deviceName": "Meldestelle",
"sharedKey": "Password",
"backupPath": "/mocode/meldestelle/docs/temp",
"networkRole": "MASTER"
"networkRole": "MASTER",
"expectedClients": [
{
"name": "Richter-Turm",
"role": "RICHTER"
}
]
}

View File

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

View File

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

View File

@ -83,13 +83,15 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
}
val filteredMails = remember(mails, searchQuery) {
if (searchQuery.isBlank()) mails
val base = if (searchQuery.isBlank()) mails
else mails.filter {
it.vorname.contains(searchQuery, ignoreCase = true) ||
it.nachname.contains(searchQuery, ignoreCase = true) ||
it.pferd.contains(searchQuery, ignoreCase = true) ||
it.turnierNr.contains(searchQuery, ignoreCase = true)
}
// Standard-Sortierung: Neueste zuerst (Status NEU oben, dann nach TurnierNr)
base.sortedWith(compareBy({ it.status != "NEU" }, { it.turnierNr }, { it.datum }))
}
// Initiales Laden
@ -108,6 +110,21 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
mails = updated
selectedMail = null
}
},
onSendReply = {
scope.launch {
repository.sendeAntwort(
email = selectedMail!!.sender,
turnierNr = selectedMail!!.turnierNr,
vorname = selectedMail!!.vorname,
nachname = selectedMail!!.nachname
)
// Nach Antwort automatisch als gelesen markieren
repository.markiereAlsGelesen(selectedMail!!.id)
val updated = mails.map { if (it.id == selectedMail!!.id) it.copy(status = "GELESEN") else it }
mails = updated
selectedMail = null
}
}
)
}
@ -212,7 +229,12 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
}
@Composable
fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkProcessed: () -> Unit) {
fun NennungDetailDialog(
mail: OnlineNennungMail,
onDismiss: () -> Unit,
onMarkProcessed: () -> Unit,
onSendReply: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Details zur Online-EntryManagement") },
@ -235,7 +257,19 @@ fun NennungDetailDialog(mail: OnlineNennungMail, onDismiss: () -> Unit, onMarkPr
}
},
confirmButton = {
Button(onClick = onMarkProcessed) { Text("Als gelesen markieren") }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (mail.status == "NEU") {
Button(
onClick = onSendReply,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32))
) {
Icon(Icons.Default.Email, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Antwort & Übernahme")
}
}
Button(onClick = onMarkProcessed) { Text("Als gelesen markieren") }
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Schließen") }

View File

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