Compare commits

...

2 Commits

Author SHA1 Message Date
b990f4dc05 docs(session-log): add session log for back-stack navigation implementation
Some checks failed
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
- Documented changes to `NavigationPort`, `DesktopNavigationPort`, and screen integration with a stack-based back-navigation approach.
- Included details on UI adjustments and enhanced "Zurück" functionality.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-04-01 17:27:12 +02:00
6fc6c8fc79 feat(management-feature): add centralized administration screens and back-navigation support
- Introduced comprehensive management screens for horses, riders, clubs, and officials.
- Integrated reusable `ManagementTableScreen` component for standardized layouts and operations.
- Added back-navigation support in `DesktopNavigationPort` with a stack-based implementation.
- Refined `DesktopMainLayout` with enhanced routing and dynamic placeholders for in-development screens.
- Updated roadmap to reflect completion of Phase 7: "Zentrale Verwaltung".

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-04-01 17:26:44 +02:00
18 changed files with 1042 additions and 121 deletions

View File

@ -186,18 +186,30 @@ und über definierte Schnittstellen kommunizieren.
## 4. Geplante Phasen
### PHASE 7: Desktop-Vernetzung & Event-First Workflow 🔵 IN ARBEIT
### PHASE 7: Desktop-Vernetzung & Zentrale Verwaltung ✅ ABGESCHLOSSEN
*Ziel: LAN-Kommunikation zwischen Apps und Fokus auf Veranstaltungs-Verwaltung.*
*Ziel: LAN-Kommunikation Vorbereitung und Etablierung der "Veranstaltung-Verwaltung" als zentrale Schaltstelle.*
* [x] **Zentrale Verwaltung:** Etablierung der `Veranstaltung-Verwaltung` (Zentrale) als administratives Cockpit.
* [x] **Navigation:** Implementierung eines Back-Stack-Systems für intelligente "Zurück"-Navigation.
* [x] **Domänen-Synchronisation:** Anpassung der Frontend-Stores an die Backend-Masterdata-Modelle (Reiter, Pferde,
Vereine, Funktionäre).
* [x] **ZNS-Integration (Frontend):** ZNS-Importer in die Zentrale integriert; Konzept "Globaler Pool -> Lokale
Synchronisation" gefestigt.
* [x] **Terminologie:** UI-weit Umstellung von "Event" auf "Veranstaltung" (ÖTO-konform).
* [x] **Konzept:** LAN-Discovery (mDNS) und Echtzeit-Sync (WebSockets) entworfen.
* [x] **ADR:** ADR-0020 (Lokale Netzwerk-Kommunikation) erstellt.
* [ ] **Discovery:** Implementierung des mDNS-Service für die Geräte-Suche.
* [ ] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync.
* [ ] **Event-First:** Umstellung des App-Startpunkts auf die Veranstaltungs-Liste (Login-Skip).
* [ ] **Wizard:** Implementierung des `VeranstaltungNeuWizard` zur Neuanlage.
### PHASE 8: Series-Context & Erweiterungen 🔵 PHASE 2+
### PHASE 8: Bewerbe-Management & Startlisten 🔵 IN ARBEIT
*Ziel: Fachliche Tiefe in den Turnieren (Import, Generierung, Zeitberechnung).*
* [ ] **Bewerbe-Import:** Implementierung der Merge-Logik (ZNS-XML -> BewerbUiModel).
* [ ] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten).
* [ ] **Discovery:** Implementierung des mDNS-Service für die Geräte-Suche (Phase 7 Übertrag).
* [ ] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync (Phase 7 Übertrag).
### PHASE 9: Series-Context & Erweiterungen 🔵 PHASE 2+
*Ziel: Cups, Serien und Meisterschaften mit konfigurierbaren Reglements.*

View File

@ -0,0 +1,75 @@
### Diagramm (Screen-Flow) - Stand 01. April 2026 (Abend-Update)
Das Frontend nutzt nun einen **Back-Stack Mechanism**, d.h. der "Zurück"-Button (TopBar) führt immer zum jeweils
vorherigen Screen im Verlauf.
```mermaid
graph TD
A[Onboarding] -- " Login / Start " --> H[Veranstaltung-Verwaltung]
subgraph "Verwaltung (Zentrale)"
H
H1[Pferde-Verwaltung]
H2[Reiter-Verwaltung]
H3[Verein-Verwaltung]
H4[Funktionär-Verwaltung]
H5[Veranstalter-Verwaltung]
H6[ZNS-Importer]
end
H -- " Pferde " --> H1
H -- " Reiter " --> H2
H -- " Vereine " --> H3
H -- " Funktionäre " --> H4
H -- " Veranstalter " --> H5
H -- " ZNS-Importer " --> H6
H6 -- " Back-Stack " --> H
H1 -- " Pferde-Profil " --> P1[Pferde-Profil]
H2 -- " Reiter-Profil " --> P2[Reiter-Profil]
H3 -- " Verein-Profil " --> P3[Verein-Profil]
H4 -- " Funktionär-Profil " --> P4[Funktionär-Profil]
H5 -- " Pferde-Profil " --> P1
P1 -- " Back-Stack " --> H
P2 -- " Back-Stack " --> H
P3 -- " Back-Stack " --> H
P4 -- " Back-Stack " --> H
H -- " Neue Veranstaltung " --> E[VeranstaltungKonfigV2]
H -- " Veranstaltung öffnen " --> F[Veranstaltung-Profil]
F -- " Turnier öffnen / Neu " --> G[TurnierDetailScreen]
F -- " Veranstalter-Profil " --> H5
subgraph "TurnierDetailScreen (Tabs)"
G1[STAMMDATEN]
G2[ORGANISATION]
G3[BEWERBE]
G4[ARTIKEL]
G5[ABRECHNUNG]
G6[NENNUNGEN]
G7[STARTLISTEN]
G8[ERGEBNISLISTEN]
end
G --> G1
G --> G3
G --> G7
F -- " Back-Stack " --> H
H5 -- " Back-Stack " --> F
H -- " Logout / Zurück " --> A
subgraph "Legacy / Administrative Screens"
L1[AdminUebersichtScreen]
L2[PingScreen]
L3[ProfileScreen]
L4[VereinScreen]
L5[VeranstaltungDetailScreen]
end
H -- " Ping " --> L2
H -- " Profil " --> L3
H -- " Vereine " --> L4
H -- " Legacy View " --> L1
%% Fallback navigation
L1 -- " Veranstalter Auswahl " --> H
L1 -- " Legacy Event Detail " --> L5
L5 -- " Turnier öffnen " --> G
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View File

@ -0,0 +1,51 @@
# 🧹 Curator Session Log (2026-04-01 - Abschluss Vormittag)
## Zusammenfassung
Die heutige Vormittags-Sitzung markiert den Übergang von einer "Sammlung von Screens" zu einer integrierten **Zentralen
Verwaltung**. Wir haben das "Chaos" im Frontend durch eine klare Hierarchie und einen intelligenten Navigations-Flow (
Back-Stack) gezähmt.
## Kern-Erkenntnisse & Architektur-Updates
### 1. Die "Zentrale" als Cockpit
- Die `Veranstaltung-Verwaltung` ist nun der primäre Einstiegspunkt nach dem Onboarding.
- Alle administrativen Domänen (Pferde, Reiter, Vereine, Funktionäre, Veranstalter) sind über diese Zentrale erreichbar.
- Dies entspricht dem Wunsch des Users nach einer "Haupt-Verwaltungs-Zentrale" (`verwaltung_01-04-26.png`).
### 2. ZNS-Datenfluss: Globaler Pool -> Lokale Synchronisation
- **Konzept:** Stammdaten (ZNS) werden global in die Desktop-App geladen (ZNS-Importer).
- **Vorteil:** Turniere müssen Daten nicht mehr isoliert halten, sondern synchronisieren sich selektiv mit dem globalen
Pool über den "Aktualisieren"-Button.
- Die Domänen-Modelle in `Stores.kt` wurden vollständig an die Backend-Modelle (`DomPferd`, `DomReiter`, etc.)
angepasst.
### 3. Navigations-Logik (Back-Stack)
- Implementierung von `navigateBack()` im `DesktopNavigationPort`.
- Der "Zurück"-Button merkt sich nun den Pfad (z.B. Veranstaltung-Profil -> Veranstalter-Profil -> Zurück ->
Veranstaltung-Profil).
### 4. Terminologie-Bereinigung
- UI-weit wurde "Event" durch **"Veranstaltung"** ersetzt (ÖTO-konform).
- Technische IDs behalten den Namen `eventId` zur Stabilität.
## Durchgeführte Code-Änderungen (Zusammenfassung)
- `AppScreen.kt`: Neue Routen für Verwaltungen und Profile.
- `ManagementScreens.kt`: Generische Tabellen-Komponente mit Suche und CRUD-Aktionen.
- `Stores.kt`: Erweiterte Datenklassen und realistische Testdaten.
- `DesktopMainLayout.kt`: Integration des Back-Stacks und der neuen Routen.
- `VeranstaltungScreens.kt`: Umbenennung in `VeranstaltungProfilScreen` und Header-Updates.
## Status der Dokumentation
- [x] **Screen-Flow:** `docs/06_Frontend/screen-flow_1-04-26.md` aktualisiert.
- [x] **Journal:** Einzelergebnisse in separaten Logs dokumentiert.
- [x] **Roadmap:** `MASTER_ROADMAP.md` auf Phase 8 (Bewerbe-Management) aktualisiert.
---
*Dokumentiert durch den Curator am 01.04.2026 um 17:40 Uhr.*

View File

@ -0,0 +1,23 @@
# Session Log - 01. April 2026 - Back-Stack Navigation
## Ziel
Implementierung einer intelligenten "Zurück"-Navigation, die sich den Verlauf der besuchten Screens merkt.
## Änderungen
- **Core Navigation**: `NavigationPort` um `navigateBack()` Methode erweitert.
- **Desktop Navigation**: `DesktopNavigationPort` mit einem internen `backStack` (MutableList) ausgestattet, um den
Verlauf zu speichern.
- **UI Layout**: `DesktopMainLayout` und `DesktopTopBar` auf `onBack` umgestellt.
- **Screen Integration**: Alle Screens im `DesktopContentArea` nutzen nun den globalen `onBack` Callback, statt fest
codierte Ziel-Screens für die Rücknavigation zu verwenden.
## Ergebnis
- Ein Klick auf den "Zurück"-Pfeil in der TopBar führt nun immer zum unmittelbar vorherigen Screen.
- Beispiel: Veranstaltung-Profil -> Veranstalter-Profil -> Zurück -> Veranstaltung-Profil (funktioniert jetzt korrekt).
## Status
- **Abgeschlossen** (Alle Screens im V2-Flow unterstützt).

View File

@ -0,0 +1,41 @@
# Session Log: 01. April 2026 - Vormittag (Zentrale & ZNS-Logik)
## 🏗️ [Lead Architect] | Status & Entscheidungen
### 1. Die "Zentrale" (Veranstaltung-Verwaltung)
Wir haben die **Veranstaltung-Verwaltung** als neue strategische Zentrale etabliert. Von hier aus sind alle
administrativen Bereiche (Pferde, Reiter, Vereine, Funktionäre, Veranstalter) erreichbar. Dies löst das "Chaos" im
Frontend durch eine klare Hierarchie.
### 2. ZNS-Datenfluss: Global -> Lokal
Ein entscheidendes Architektur-Konzept wurde heute Vormittag gefestigt:
* **Globaler Pool:** ZNS-Stammdaten (Pferde, Personen, Vereine) werden über den ZNS-Importer in die globale Datenbank
der Desktop-App geladen.
* **Lokale Synchronisation:** In den Turnier-Details (z.B. `TurnierBewerbeTab`) dient der Button **"Aktualisieren"**
dazu, die Daten für dieses spezifische Turnier mit dem globalen Pool abzugleichen.
* **Vorteil:** Daten müssen nicht pro Turnier neu importiert werden. Ein globaler Stand (z.B. nach einem ZNS-Update)
kann selektiv in die aktiven Turniere "gepusht" werden.
### 3. Terminologie-Bereinigung
Alle UI-Texte wurden auf **"Veranstaltung"** umgestellt, um konform mit der ÖTO (§ 2 Abs. 1) zu sein. "Event" bleibt ein
technischer Begriff im Code.
## 👷 [Backend/Frontend] | Durchgeführte Änderungen
* **App-Routing:** `AppScreen.kt` um neue Verwaltungs-Routen erweitert.
* **Navigation:** `DesktopMainLayout.kt` implementiert nun den Flow von der Zentrale in die Fachbereiche.
* **Importer-Integration:** Der ZNS-Importer ist nun direkt aus der Zentrale erreichbar.
* **Bugfix:** Kompilierfehler in der Navigation (fehlender `onBack` Parameter) behoben.
## 🧐 [QA Specialist] | Offene Punkte für den Nachmittag
* [ ] **Bewerbe-Import:** Implementierung der konkreten Merge-Logik (ZNS-XML -> `BewerbUiModel`).
* [ ] **Startlisten-Sortierung:** Validierung der ÖTO-konformen Auslosung.
* [ ] **Profil-Screens:** Die Placeholder für Pferde-, Reiter- etc. Profile müssen mit Leben gefüllt werden.
---
*Dokumentiert durch den Curator am 01.04.2026*

View File

@ -18,7 +18,25 @@ sealed class AppScreen(val route: String) {
data object Nennung : AppScreen("/nennung")
// --- Desktop-Navigation (Vision_03) ---
data object Veranstaltungen : AppScreen("/veranstaltungen")
data object VeranstaltungVerwaltung : AppScreen("/verwaltung") // Gesamtübersicht
// Profile
data object PferdVerwaltung : AppScreen("/pferde/verwaltung")
data class PferdProfil(val id: Long) : AppScreen("/pferde/profil/$id")
data object ReiterVerwaltung : AppScreen("/reiter/verwaltung")
data class ReiterProfil(val id: Long) : AppScreen("/reiter/profil/$id")
data object VereinVerwaltung : AppScreen("/vereine/verwaltung")
data class VereinProfil(val id: Long) : AppScreen("/vereine/profil/$id")
data object FunktionaerVerwaltung : AppScreen("/funktionaere/verwaltung")
data class FunktionaerProfil(val id: Long) : AppScreen("/funktionaere/profil/$id")
data object VeranstalterVerwaltung : AppScreen("/veranstalter/verwaltung")
data class VeranstalterProfil(val id: Long) : AppScreen("/veranstalter/profil/$id")
// data class VeranstaltungProfil(val id: Long) : AppScreen("/veranstaltung/profil/$id")
// Neuer Flow: + Neue Veranstaltung → Veranstalter auswählen → Veranstalter-Detail → Veranstaltung-Übersicht
data object VeranstalterAuswahl : AppScreen("/veranstalter/auswahl")
@ -28,7 +46,7 @@ sealed class AppScreen(val route: String) {
// Neue Veranstaltungs-Konfig-Seite (aus Veranstalter-Detail oder direkt aus Cockpit)
data class VeranstaltungKonfig(val veranstalterId: Long = 0) :
AppScreen("/veranstalter/$veranstalterId/veranstaltung/neu")
data class VeranstaltungUebersicht(val veranstalterId: Long, val veranstaltungId: Long) :
data class VeranstaltungProfil(val veranstalterId: Long, val veranstaltungId: Long) :
AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId")
data class VeranstaltungDetail(val id: Long) : AppScreen("/veranstaltung/$id")
@ -51,7 +69,14 @@ sealed class AppScreen(val route: String) {
private val TURNIER_NEU = Regex("/veranstaltung/(\\d+)/turnier/neu$")
private val VERANSTALTER_DETAIL = Regex("/veranstalter/(\\d+)$")
private val VERANSTALTUNG_KONFIG = Regex("/veranstalter/(\\d+)/veranstaltung/neu$")
private val VERANSTALTUNG_UEBERSICHT = Regex("/veranstalter/(\\d+)/veranstaltung/(\\d+)$")
private val VERANSTALTUNG_PROFIL = Regex("/veranstalter/(\\d+)/veranstaltung/(\\d+)$")
private val PFERD_PROFIL = Regex("/pferde/profil/(\\d+)$")
private val REITER_PROFIL = Regex("/reiter/profil/(\\d+)$")
private val VEREIN_PROFIL = Regex("/vereine/profil/(\\d+)$")
private val FUNKTIONAER_PROFIL = Regex("/funktionaere/profil/(\\d+)$")
private val VERANSTALTER_PROFIL = Regex("/veranstalter/profil/(\\d+)$")
// private val VERANSTALTUNG_PROFIL_LEGACY = Regex("/veranstaltung/profil/(\\d+)$")
fun fromRoute(route: String): AppScreen {
return when (route) {
@ -66,17 +91,27 @@ sealed class AppScreen(val route: String) {
"/organizer/profile" -> OrganizerProfile
"/auth/callback" -> AuthCallback
"/nennung" -> Nennung
"/veranstaltungen" -> Veranstaltungen
"/verwaltung" -> VeranstaltungVerwaltung
"/pferde/verwaltung" -> PferdVerwaltung
"/reiter/verwaltung" -> ReiterVerwaltung
"/vereine/verwaltung" -> VereinVerwaltung
"/funktionaere/verwaltung" -> FunktionaerVerwaltung
"/veranstalter/verwaltung" -> VeranstalterVerwaltung
"/veranstalter/auswahl" -> VeranstalterAuswahl
"/veranstaltung/neu" -> VeranstaltungNeu
"/reiter" -> Reiter
"/pferde" -> Pferde
"/vereine" -> Vereine
"/funktionaere" -> Funktionaere
"/meisterschaften" -> Meisterschaften
"/cups" -> Cups
"/stammdaten/import" -> StammdatenImport
else -> {
PFERD_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return PferdProfil(id.toLong()) }
REITER_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return ReiterProfil(id.toLong()) }
VEREIN_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return VereinProfil(id.toLong()) }
FUNKTIONAER_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return FunktionaerProfil(id.toLong()) }
VERANSTALTER_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return VeranstalterProfil(id.toLong()) }
/*
VERANSTALTUNG_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return VeranstaltungProfil(id.toLong()) }
*/
TURNIER_DETAIL.matchEntire(route)?.destructured?.let { (vId, tId) ->
return TurnierDetail(vId.toLong(), tId.toLong())
}
@ -92,8 +127,8 @@ sealed class AppScreen(val route: String) {
VERANSTALTUNG_KONFIG.matchEntire(route)?.destructured?.let { (vId) ->
return VeranstaltungKonfig(vId.toLong())
}
VERANSTALTUNG_UEBERSICHT.matchEntire(route)?.destructured?.let { (verId, vId) ->
return VeranstaltungUebersicht(verId.toLong(), vId.toLong())
VERANSTALTUNG_PROFIL.matchEntire(route)?.destructured?.let { (verId, vId) ->
return VeranstaltungProfil(verId.toLong(), vId.toLong())
}
Landing // Default fallback
}

View File

@ -15,4 +15,7 @@ interface NavigationPort {
/** Typsichere Navigation direkt via AppScreen-Objekt. */
fun navigateToScreen(screen: AppScreen)
/** Gehe einen Schritt zurück im Verlauf. */
fun navigateBack()
}

View File

@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@ -21,6 +22,7 @@ import javax.swing.filechooser.FileNameExtensionFilter
@Composable
fun StammdatenImportScreen(
viewModel: ZnsImportViewModel = koinViewModel(),
onBack: () -> Unit,
) {
val state = viewModel.state
@ -32,6 +34,9 @@ fun StammdatenImportScreen(
) {
// Titel
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Icon(Icons.Default.CloudUpload, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text("Stammdaten-Import (ZNS)", style = MaterialTheme.typography.headlineSmall)
}

View File

@ -37,16 +37,17 @@ fun DesktopApp() {
val authState by authTokenManager.authState.collectAsState()
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
// Vision_03 Update: Wir starten direkt in der Veranstaltungs-Übersicht (Offline-First)
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.Veranstaltungen
// Vision_03 Update: Wir starten mit Onboarding
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.Onboarding
&& currentScreen !is AppScreen.VeranstaltungVerwaltung
&& currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.VeranstaltungKonfig
&& currentScreen !is AppScreen.VeranstaltungUebersicht && currentScreen !is AppScreen.TurnierDetail
&& currentScreen !is AppScreen.TurnierNeu && currentScreen !is AppScreen.Vereine
&& currentScreen !is AppScreen.VeranstaltungProfil && currentScreen !is AppScreen.TurnierDetail
&& currentScreen !is AppScreen.TurnierNeu
) {
LaunchedEffect(Unit) {
// Standard: Direkt zur Veranstaltungs-Übersicht (Offline-First-Modus)
nav.navigateToScreen(AppScreen.Veranstaltungen)
// Standard: Start im Onboarding
nav.navigateToScreen(AppScreen.Onboarding)
}
}
@ -54,7 +55,7 @@ fun DesktopApp() {
is AppScreen.Login -> LoginScreen(
viewModel = loginViewModel,
onLoginSuccess = {
val returnTo = screen.returnTo ?: AppScreen.Veranstaltungen
val returnTo = screen.returnTo ?: AppScreen.VeranstaltungVerwaltung
nav.navigateToScreen(returnTo)
},
onBack = { /* Desktop hat keine Landing-Page */ },
@ -65,9 +66,10 @@ fun DesktopApp() {
DesktopMainLayout(
currentScreen = screen,
onNavigate = { nav.navigateToScreen(it) },
onBack = { nav.navigateBack() },
onLogout = {
authTokenManager.clearToken()
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Veranstaltungen))
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.VeranstaltungVerwaltung))
},
)
}

View File

@ -14,14 +14,35 @@ class DesktopNavigationPort : NavigationPort {
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Onboarding)
override val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
// Backstack zur Speicherung des Verlaufs
private val backStack = mutableListOf<AppScreen>()
override fun navigateTo(route: String) {
val screen = AppScreen.fromRoute(route)
println("[DesktopNav] navigateTo $route -> $screen")
_currentScreen.value = screen
navigateToScreen(screen)
}
override fun navigateToScreen(screen: AppScreen) {
println("[DesktopNav] navigateToScreen -> $screen")
// Aktuellen Screen auf den Stack legen, falls er nicht derselbe ist
val current = _currentScreen.value
if (current != screen) {
backStack.add(current)
// Begrenzung des Backstacks auf z.B. 50 Einträge
if (backStack.size > 50) backStack.removeAt(0)
}
_currentScreen.value = screen
}
override fun navigateBack() {
if (backStack.isNotEmpty()) {
val previousScreen = backStack.removeAt(backStack.size - 1)
println("[DesktopNav] navigateBack -> $previousScreen")
_currentScreen.value = previousScreen
} else {
println("[DesktopNav] navigateBack -> Stack leer, bleibe bei Onboarding")
_currentScreen.value = AppScreen.Onboarding
}
}
}

View File

@ -5,14 +5,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -56,12 +53,14 @@ private val TopBarTextColor = Color.White
fun DesktopMainLayout(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onBack: () -> Unit,
onLogout: () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
DesktopTopBar(
currentScreen = currentScreen,
onNavigate = onNavigate,
onBack = onBack,
onLogout = onLogout,
)
Column(modifier = Modifier.fillMaxSize()) {
@ -69,6 +68,7 @@ fun DesktopMainLayout(
DesktopContentArea(
currentScreen = currentScreen,
onNavigate = onNavigate,
onBack = onBack,
)
}
DesktopFooterBar()
@ -89,6 +89,7 @@ fun DesktopMainLayout(
private fun DesktopTopBar(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onBack: () -> Unit,
onLogout: () -> Unit,
) {
Row(
@ -102,25 +103,25 @@ private fun DesktopTopBar(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Zurück-Pfeil (nur wenn nicht Root)
if (currentScreen !is AppScreen.Veranstaltungen) {
if (currentScreen !is AppScreen.VeranstaltungVerwaltung) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
tint = TopBarTextColor,
modifier = Modifier
.size(20.dp)
.clickable { onNavigate(AppScreen.Veranstaltungen) },
.clickable { onBack() },
)
Spacer(Modifier.width(8.dp))
}
// Root-Link
Text(
text = "Veranstaltungen",
text = "Verwaltung",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.clickable { onNavigate(AppScreen.Veranstaltungen) },
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstaltungVerwaltung) },
)
// Breadcrumb-Segmente je nach Screen
@ -166,7 +167,7 @@ private fun DesktopTopBar(
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.VeranstaltungUebersicht -> {
is AppScreen.VeranstaltungProfil -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
@ -305,6 +306,27 @@ private fun InvalidContextNotice(message: String, onBack: () -> Unit) {
}
}
@Composable
fun PlaceholderScreen(
title: String,
onBack: () -> Unit,
onAction: (() -> Unit)? = null,
actionLabel: String = "Aktion ausführen"
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(title, style = MaterialTheme.typography.headlineMedium)
Text("Dieser Screen ist noch in Arbeit (Placeholder)", color = Color.Gray)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onBack) { Text("Zurück") }
if (onAction != null) {
Button(onClick = onAction) { Text(actionLabel) }
}
}
}
}
}
/**
* Content-Bereich: rendert den passenden Screen je nach aktuellem AppScreen.
*/
@ -312,49 +334,133 @@ private fun InvalidContextNotice(message: String, onBack: () -> Unit) {
private fun DesktopContentArea(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onBack: () -> Unit,
) {
when (currentScreen) {
// Onboarding ohne Login
is AppScreen.Onboarding -> {
val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject()
// V2 Onboarding (Vision_03)
at.mocode.desktop.v2.OnboardingScreenV2 { _, _ ->
at.mocode.desktop.v2.OnboardingScreen { _, _ ->
authTokenManager.setToken("dummy.jwt.token")
onNavigate(AppScreen.VeranstalterAuswahl)
onNavigate(AppScreen.VeranstaltungVerwaltung)
}
}
// Root-Screen: Leitet in V2-Fluss ab
is AppScreen.Veranstaltungen -> {
at.mocode.desktop.v2.VeranstaltungenUebersichtV2(
onEventOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, eId)) },
onNewEvent = { onNavigate(AppScreen.VeranstaltungKonfig()) }
// Haupt-Zentrale: Veranstaltung-Verwaltung
is AppScreen.VeranstaltungVerwaltung -> {
at.mocode.desktop.v2.VeranstaltungVerwaltungV2(
onVeranstaltungOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) },
onNewVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig()) },
onNavigateToPferde = { onNavigate(AppScreen.PferdVerwaltung) },
onNavigateToReiter = { onNavigate(AppScreen.ReiterVerwaltung) },
onNavigateToVereine = { onNavigate(AppScreen.VereinVerwaltung) },
onNavigateToFunktionaere = { onNavigate(AppScreen.FunktionaerVerwaltung) },
onNavigateToVeranstalter = { onNavigate(AppScreen.VeranstalterVerwaltung) },
onNavigateToZnsImport = { onNavigate(AppScreen.StammdatenImport) }
)
}
// --- ZNS Importer ---
is AppScreen.StammdatenImport -> {
at.mocode.zns.feature.presentation.StammdatenImportScreen(
onBack = onBack
)
}
// --- Pferde-Verwaltung & Profil ---
is AppScreen.PferdVerwaltung -> at.mocode.desktop.v2.PferdeVerwaltungScreen(
onBack = onBack,
onEdit = { onNavigate(AppScreen.PferdProfil(it)) }
)
is AppScreen.PferdProfil -> PlaceholderScreen(
"Pferde-Profil #${currentScreen.id}",
onBack = onBack,
onAction = { onNavigate(AppScreen.VeranstaltungVerwaltung) },
actionLabel = "Zurück zur Zentrale"
)
// --- Reiter-Verwaltung & Profil ---
is AppScreen.ReiterVerwaltung -> at.mocode.desktop.v2.ReiterVerwaltungScreen(
onBack = onBack,
onEdit = { onNavigate(AppScreen.ReiterProfil(it)) }
)
is AppScreen.ReiterProfil -> PlaceholderScreen(
"Reiter-Profil #${currentScreen.id}",
onBack = onBack,
onAction = { onNavigate(AppScreen.VeranstaltungVerwaltung) },
actionLabel = "Zurück zur Zentrale"
)
// --- Verein-Verwaltung & Profil ---
is AppScreen.VereinVerwaltung -> at.mocode.desktop.v2.VereinVerwaltungScreen(
onBack = onBack,
onEdit = { onNavigate(AppScreen.VereinProfil(it)) }
)
is AppScreen.VereinProfil -> PlaceholderScreen(
"Verein-Profil #${currentScreen.id}",
onBack = onBack,
onAction = { onNavigate(AppScreen.VereinVerwaltung) },
actionLabel = "Zurück zur Zentrale"
)
// --- Funktionaer-Verwaltung & Profil ---
is AppScreen.FunktionaerVerwaltung -> at.mocode.desktop.v2.FunktionaerVerwaltungScreen(
onBack = onBack,
onEdit = { onNavigate(AppScreen.FunktionaerProfil(it)) }
)
is AppScreen.FunktionaerProfil -> PlaceholderScreen(
"Funktionär-Profil #${currentScreen.id}",
onBack = onBack,
onAction = { onNavigate(AppScreen.FunktionaerVerwaltung) },
actionLabel = "Zurück zur Zentrale"
)
// --- Veranstalter-Verwaltung & Profil ---
is AppScreen.VeranstalterVerwaltung -> at.mocode.desktop.v2.VeranstalterVerwaltungScreen(
onBack = onBack,
onEdit = { onNavigate(AppScreen.VeranstalterProfil(it)) }
)
is AppScreen.VeranstalterProfil -> PlaceholderScreen(
"Veranstalter-Profil #${currentScreen.id}",
onBack = onBack,
onAction = { onNavigate(AppScreen.PferdProfil(1L)) },
actionLabel = "Pferde-Profil öffnen"
)
/*
is AppScreen.VeranstaltungProfil -> PlaceholderScreen("Veranstaltung-Profil #${currentScreen.id}",
onBack = { onNavigate(AppScreen.VeranstaltungVerwaltung) }
)
*/
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht
is AppScreen.VeranstalterAuswahl -> at.mocode.desktop.v2.VeranstalterAuswahlV2(
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onBack = onBack,
onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) },
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
)
is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen(
onAbbrechen = { onNavigate(AppScreen.VeranstalterAuswahl) },
onSpeichern = { _, _, _ -> onNavigate(AppScreen.VeranstalterAuswahl) },
onAbbrechen = onBack,
onSpeichern = { _, _, _ -> onBack() },
)
is AppScreen.VeranstalterDetail -> {
val vId = currentScreen.veranstalterId
if (!FakeVeranstalterStore.exists(vId)) {
InvalidContextNotice(
message = "Veranstalter (ID=$vId) nicht gefunden.",
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
onBack = onBack
)
} else {
at.mocode.desktop.v2.VeranstalterDetailV2(
veranstalterId = vId,
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) },
onZurVeranstaltung = { evtId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) },
onBack = onBack,
onZurVeranstaltung = { evtId -> onNavigate(AppScreen.VeranstaltungProfil(vId, evtId)) },
onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig(vId)) },
)
}
@ -364,32 +470,30 @@ private fun DesktopContentArea(
// Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard
at.mocode.desktop.v2.VeranstaltungKonfigV2(
veranstalterId = vId,
onBack = {
if (vId == 0L) onNavigate(AppScreen.Veranstaltungen)
else onNavigate(AppScreen.VeranstalterDetail(vId))
},
onSaved = { evtId, finalVId -> onNavigate(AppScreen.VeranstaltungUebersicht(finalVId, evtId)) },
onBack = onBack,
onSaved = { evtId, finalVId -> onNavigate(AppScreen.VeranstaltungProfil(finalVId, evtId)) },
onVeranstalterCreated = { newVId -> onNavigate(AppScreen.VeranstalterDetail(newVId)) }
)
}
is AppScreen.VeranstaltungUebersicht -> {
is AppScreen.VeranstaltungProfil -> {
val vId = currentScreen.veranstalterId
val evtId = currentScreen.veranstaltungId
if (at.mocode.desktop.v2.StoreV2.vereine.none { it.id == vId }) {
InvalidContextNotice(
message = "Veranstalter (ID=$vId) nicht gefunden.",
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
onBack = onBack
)
} else if (at.mocode.desktop.v2.StoreV2.eventsFor(vId).none { it.id == evtId }) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) gehört nicht zu Veranstalter #$vId.",
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) }
onBack = onBack
)
} else {
at.mocode.desktop.v2.VeranstaltungUebersichtV2(
at.mocode.desktop.v2.VeranstaltungProfilScreen(
veranstalterId = vId,
veranstaltungId = evtId,
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) },
onBack = onBack,
onTurnierNeu = {
val veranstaltung = at.mocode.desktop.v2.StoreV2.eventsFor(vId).firstOrNull { it.id == evtId }
val list = at.mocode.desktop.v2.TurnierStoreV2.list(evtId)
@ -405,6 +509,7 @@ private fun DesktopContentArea(
onNavigate(AppScreen.TurnierDetail(evtId, newId))
},
onTurnierOpen = { tId -> onNavigate(AppScreen.TurnierDetail(evtId, tId)) },
onNavigateToVeranstalterProfil = { verId -> onNavigate(AppScreen.VeranstalterProfil(verId)) }
)
}
}
@ -412,7 +517,7 @@ private fun DesktopContentArea(
// Veranstaltungs-Screens
is AppScreen.VeranstaltungDetail -> VeranstaltungDetailScreen(
veranstaltungId = currentScreen.id,
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onBack = onBack,
onTurnierNeu = {
val v = at.mocode.desktop.v2.StoreV2.vereine.firstOrNull { vv ->
at.mocode.desktop.v2.StoreV2.eventsFor(vv.id).any { it.id == currentScreen.id }
@ -433,8 +538,8 @@ private fun DesktopContentArea(
onTurnierOeffnen = { tid -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tid)) },
)
is AppScreen.VeranstaltungNeu -> VeranstaltungNeuScreen(
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onSave = { onNavigate(AppScreen.Veranstaltungen) },
onBack = onBack,
onSave = { onBack() },
)
// Turnier-Screens
@ -446,7 +551,7 @@ private fun DesktopContentArea(
if (parent == null) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) nicht gefunden.",
onBack = { onNavigate(AppScreen.Veranstaltungen) }
onBack = onBack
)
} else {
val veranstaltung = at.mocode.desktop.v2.StoreV2.eventsFor(parent.id).firstOrNull { it.id == evtId }
@ -455,7 +560,7 @@ private fun DesktopContentArea(
TurnierDetailScreen(
veranstaltungId = evtId,
turnierId = currentScreen.turnierId,
onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
onBack = onBack,
eventVon = veranstaltung?.datumVon,
eventBis = veranstaltung?.datumBis,
eventOrt = veranstaltung?.ort,
@ -475,14 +580,14 @@ private fun DesktopContentArea(
if (parent == null) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) nicht gefunden.",
onBack = { onNavigate(AppScreen.Veranstaltungen) }
onBack = onBack
)
} else {
at.mocode.desktop.v2.TurnierWizardV2(
veranstalterId = parent.id,
veranstaltungId = evtId,
onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
onSaved = { _ -> onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
onBack = onBack,
onSaved = { _ -> onBack() },
)
}
}
@ -492,7 +597,7 @@ private fun DesktopContentArea(
val pingViewModel: PingViewModel = koinInject()
PingScreen(
viewModel = pingViewModel,
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onBack = onBack,
)
}
@ -556,7 +661,7 @@ private fun DesktopFooterBar() {
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceConnected.value) {
OutlinedButton(onClick = { /* öffne Chat-Panel */ }, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) {
Icon(Icons.Filled.Chat, contentDescription = null, tint = Color(0xFF2563EB))
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null, tint = Color(0xFF2563EB))
Spacer(Modifier.width(6.dp))
Text("Chat", color = Color(0xFF2563EB), fontSize = 12.sp)
}

View File

@ -0,0 +1,276 @@
package at.mocode.desktop.v2
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun <T> ManagementTableScreen(
title: String,
items: List<T>,
columns: List<TableColumn<T>>,
onBack: () -> Unit,
onNew: () -> Unit,
onEdit: (T) -> Unit,
onDelete: (T) -> Unit,
onSearch: (String) -> Unit = {}
) {
var searchQuery by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
Text(title, style = MaterialTheme.typography.headlineMedium)
}
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = searchQuery,
onValueChange = {
searchQuery = it
onSearch(it)
},
placeholder = { Text("Suchen...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier.width(300.dp).padding(end = 16.dp),
singleLine = true
)
Button(onClick = onNew) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Neu anlegen")
}
}
}
Spacer(Modifier.height(16.dp))
// Tabelle
Card(modifier = Modifier.fillMaxWidth().weight(1f)) {
Column(modifier = Modifier.fillMaxSize()) {
// Table Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 2.dp
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
columns.forEach { col ->
Text(
text = col.header,
modifier = if (col.weight != null) Modifier.weight(col.weight) else Modifier.width(col.width ?: 150.dp),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.width(100.dp)) // Platz für Aktionen
}
}
Divider()
// Table Body
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEdit(item) }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
columns.forEach { col ->
Text(
text = col.cellValue(item),
modifier = if (col.weight != null) Modifier.weight(col.weight) else Modifier.width(
col.width ?: 150.dp
),
style = MaterialTheme.typography.bodyMedium
)
}
// Aktionen
Row(modifier = Modifier.width(100.dp), horizontalArrangement = Arrangement.End) {
IconButton(onClick = { onEdit(item) }) {
Icon(Icons.Default.Edit, contentDescription = "Bearbeiten", tint = MaterialTheme.colorScheme.primary)
}
if (title != "Veranstalter-Verwaltung") {
IconButton(onClick = { onDelete(item) }) {
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = MaterialTheme.colorScheme.error)
}
}
}
}
Divider()
}
}
}
}
}
}
data class TableColumn<T>(
val header: String,
val cellValue: (T) -> String,
val width: androidx.compose.ui.unit.Dp? = null,
val weight: Float? = null
)
@Composable
fun PferdeVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) {
val pferde = StoreV2.pferde
var filter by remember { mutableStateOf("") }
val filteredItems = if (filter.isEmpty()) pferde else pferde.filter {
it.name.contains(filter, ignoreCase = true) || it.feiId?.contains(filter, ignoreCase = true) == true
}
ManagementTableScreen(
title = "Pferde-Verwaltung",
items = filteredItems,
columns = listOf(
TableColumn("Name", { it.name }, weight = 1.5f),
TableColumn("ÖPS-Nr.", { it.oepsNummer ?: "-" }, width = 100.dp),
TableColumn("FEI-ID", { it.feiId ?: "-" }, width = 100.dp),
TableColumn("Lebensnr.", { it.lebensnummer ?: "-" }, width = 150.dp),
TableColumn("Geschl.", { it.geschlecht }, width = 80.dp),
TableColumn("Farbe", { it.farbe ?: "-" }, width = 100.dp),
TableColumn("Geb.Datum", { it.geburtsdatum ?: "-" }, width = 100.dp),
TableColumn("Besitzer", { it.besitzer ?: "-" }, weight = 1f)
),
onBack = onBack,
onNew = { /* CRUD Logik */ },
onEdit = { onEdit(it.id) },
onDelete = { StoreV2.pferde.remove(it) },
onSearch = { filter = it }
)
}
@Composable
fun ReiterVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) {
val reiter = StoreV2.reiter
var filter by remember { mutableStateOf("") }
val filteredItems = if (filter.isEmpty()) reiter else reiter.filter {
it.vorname.contains(filter, ignoreCase = true) || it.nachname.contains(
filter,
ignoreCase = true
) || it.oepsNummer?.contains(filter, ignoreCase = true) == true
}
ManagementTableScreen(
title = "Reiter-Verwaltung",
items = filteredItems,
columns = listOf(
TableColumn("Name", { "${it.vorname} ${it.nachname}" }, weight = 1.5f),
TableColumn("ÖPS-Nr.", { it.oepsNummer ?: "-" }, width = 100.dp),
TableColumn("Lizenz", { it.lizenzKlasse }, width = 100.dp),
TableColumn("Startk.", { if (it.startkartAktiv) "Ja (${it.startkartSaison})" else "Nein" }, width = 100.dp),
TableColumn("Verein", { it.verein ?: "-" }, weight = 1.5f),
TableColumn("Nation", { it.nation }, width = 80.dp)
),
onBack = onBack,
onNew = { },
onEdit = { onEdit(it.id) },
onDelete = { StoreV2.reiter.remove(it) },
onSearch = { filter = it }
)
}
@Composable
fun VereinVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) {
val vereine = StoreV2.vereine
var filter by remember { mutableStateOf("") }
val filteredItems = if (filter.isEmpty()) vereine else vereine.filter {
it.name.contains(filter, ignoreCase = true) || it.oepsNummer.contains(filter, ignoreCase = true)
}
ManagementTableScreen(
title = "Verein-Verwaltung",
items = filteredItems,
columns = listOf(
TableColumn("Name", { it.name }, weight = 2f),
TableColumn("ÖPS-Nr.", { it.oepsNummer }, width = 100.dp),
TableColumn("BL", { it.bundesland ?: "-" }, width = 60.dp),
TableColumn("Ort", { it.ort ?: "-" }, weight = 1f),
TableColumn("Veranst.", { if (it.istVeranstalter) "Ja" else "Nein" }, width = 80.dp)
),
onBack = onBack,
onNew = { },
onEdit = { onEdit(it.id) },
onDelete = { StoreV2.vereine.remove(it) },
onSearch = { filter = it }
)
}
@Composable
fun FunktionaerVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) {
val funktionaere = StoreV2.funktionaere
var filter by remember { mutableStateOf("") }
val filteredItems = if (filter.isEmpty()) funktionaere else funktionaere.filter {
it.vorname.contains(filter, ignoreCase = true) || it.nachname.contains(filter, ignoreCase = true)
}
ManagementTableScreen(
title = "Funktionär-Verwaltung",
items = filteredItems,
columns = listOf(
TableColumn("Name", { "${it.vorname} ${it.nachname}" }, weight = 1.5f),
TableColumn("Nr.", { it.richterNummer ?: "-" }, width = 100.dp),
TableColumn("Rollen", { it.rollen.joinToString(", ") }, weight = 1.2f),
TableColumn("Quali", { it.richterQualifikation ?: "-" }, width = 120.dp),
TableColumn("Sparten", { it.qualifiziertFuerSparten.joinToString(", ") }, weight = 1.2f)
),
onBack = onBack,
onNew = { },
onEdit = { onEdit(it.id) },
onDelete = { StoreV2.funktionaere.remove(it) },
onSearch = { filter = it }
)
}
@Composable
fun VeranstalterVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) {
// Veranstalter sind in unserem System eigentlich Vereine, die Veranstaltungen ausrichten
// Wir nutzen hier die 'vereine' Liste aus dem Store.
val vereine = StoreV2.vereine
var filter by remember { mutableStateOf("") }
val filteredItems = if (filter.isEmpty()) vereine else vereine.filter {
it.name.contains(filter, ignoreCase = true) || it.oepsNummer.contains(filter, ignoreCase = true)
}
ManagementTableScreen(
title = "Veranstalter-Verwaltung",
items = filteredItems,
columns = listOf(
TableColumn("Name", { it.name }, weight = 2f),
TableColumn("ÖPS-Nr.", { it.oepsNummer }, width = 100.dp),
TableColumn("Ort", { it.ort ?: "-" }, weight = 1f),
TableColumn("BL", { it.bundesland ?: "-" }, width = 60.dp),
TableColumn("Email", { it.email ?: "-" }, weight = 1f)
),
onBack = onBack,
onNew = { },
onEdit = { onEdit(it.id) },
onDelete = { },
onSearch = { filter = it }
)
}

View File

@ -20,7 +20,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun OnboardingScreenV2(onContinue: (String, String) -> Unit) {
fun OnboardingScreen(onContinue: (String, String) -> Unit) {
DesktopThemeV2 {
Surface(color = MaterialTheme.colorScheme.background) {
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
@ -139,9 +139,9 @@ fun VeranstalterDetailV2(
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = verein.ansprechpartner ?: "",
onValueChange = { verein.ansprechpartner = it.ifBlank { null } },
label = { Text("Ansprechpartner (optional)") },
value = verein.ort ?: "",
onValueChange = { verein.ort = it.ifBlank { null } },
label = { Text("Ansprechpartner / Ort (optional)") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
@ -166,9 +166,9 @@ fun VeranstalterDetailV2(
)
}
OutlinedTextField(
value = verein.adresse ?: "",
onValueChange = { verein.adresse = it.ifBlank { null } },
label = { Text("Adresse (optional)") },
value = verein.strasse ?: "",
onValueChange = { verein.strasse = it.ifBlank { null } },
label = { Text("Adresse / Straße (optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2
)

View File

@ -7,13 +7,68 @@ data class Verein(
val id: Long,
var name: String,
var oepsNummer: String,
var ort: String,
// Profil-Felder (minimal laut Abstimmung)
var logoUrl: String? = null,
var ansprechpartner: String? = null,
var kurzname: String? = null,
var bundesland: String? = null,
var ort: String? = null,
var plz: String? = null,
var strasse: String? = null,
var email: String? = null,
var telefon: String? = null,
var adresse: String? = null,
var website: String? = null,
var istVeranstalter: Boolean = false,
var logoUrl: String? = null,
var bemerkungen: String? = null,
)
data class Pferd(
val id: Long,
var name: String,
var geschlecht: String = "Stute",
var geburtsdatum: String? = null,
var rasse: String? = null,
var farbe: String? = null,
var lebensnummer: String? = null,
var chipNummer: String? = null,
var passNummer: String? = null,
var oepsNummer: String? = null,
var feiId: String? = null,
var vater: String? = null,
var mutter: String? = null,
var mutterVater: String? = null,
var stockmass: Int? = null,
var besitzer: String? = null,
var istAktiv: Boolean = true,
)
data class Reiter(
val id: Long,
var vorname: String,
var nachname: String,
var satznummer: String? = null,
var oepsNummer: String? = null,
var feiId: String? = null,
var lizenzKlasse: String = "LIZENZFREI",
var startkartAktiv: Boolean = false,
var startkartSaison: Int? = null,
var geburtsdatum: String? = null,
var vereinsNummer: String? = null,
var verein: String? = null,
var nation: String = "AUT",
var istGastreiter: Boolean = false,
)
data class Funktionaer(
val id: Long,
var vorname: String,
var nachname: String,
var richterNummer: String? = null,
var rollen: List<String> = emptyList(),
var richterQualifikation: String? = null,
var qualifiziertFuerSparten: List<String> = emptyList(),
var email: String? = null,
var telefon: String? = null,
var vereinsNummer: String? = null,
var istAktiv: Boolean = true,
)
data class VeranstaltungV2(
@ -31,23 +86,185 @@ data class VeranstaltungV2(
)
object StoreV2 {
val pferde: SnapshotStateList<Pferd> = mutableStateListOf(
Pferd(
id = 1,
name = "Don Johnson",
feiId = "104FE22",
geschlecht = "Wallach",
farbe = "Fuchs",
vater = "Don Frederico",
mutter = "Waikiki",
geburtsdatum = "2001-01-01",
besitzer = "Isabell Werth",
lebensnummer = "DE 431316694401",
oepsNummer = "3H66"
),
Pferd(
id = 2,
name = "Bella Rose",
feiId = "103RW04",
geschlecht = "Stute",
farbe = "Fuchs",
vater = "Belissimo M",
mutter = "Cadra II",
geburtsdatum = "2004-01-01",
besitzer = "Madeleine Winter-Schulze",
lebensnummer = "DE 443434443904",
oepsNummer = "2T15"
),
Pferd(
id = 3,
name = "Valegro",
feiId = "102UB51",
geschlecht = "Wallach",
farbe = "Brauner",
vater = "Negro",
mutter = "Maifleur",
geburtsdatum = "2002-01-01",
besitzer = "Carl Hester & Roly Luard",
lebensnummer = "NLD003NL0204840",
oepsNummer = "1V51"
),
Pferd(
id = 4,
name = "Dalera BB",
feiId = "104UD89",
geschlecht = "Stute",
farbe = "Brauner",
vater = "Easy Game",
mutter = "Dark Magic",
geburtsdatum = "2007-01-01",
besitzer = "Beatrice Bürchler-Keller",
lebensnummer = "DE 409090124007",
oepsNummer = "4U89"
),
)
val reiter: SnapshotStateList<Reiter> = mutableStateListOf(
Reiter(
id = 1,
vorname = "Isabell",
nachname = "Werth",
oepsNummer = "O-12345",
feiId = "10011469",
verein = "RFV Graf von Schmettow Eversael",
lizenzKlasse = "RD4",
startkartAktiv = true,
startkartSaison = 2026,
nation = "GER"
),
Reiter(
id = 2,
vorname = "Jessica",
nachname = "von Bredow-Werndl",
oepsNummer = "O-54321",
feiId = "10019075",
verein = "RFV Aubenhausen",
lizenzKlasse = "RD4",
startkartAktiv = true,
startkartSaison = 2026,
nation = "GER"
),
Reiter(
id = 3,
vorname = "Charlotte",
nachname = "Dujardin",
oepsNummer = "GB-9999",
feiId = "10028445",
verein = "Rowallan Activity Centre",
lizenzKlasse = "RD4",
startkartAktiv = true,
startkartSaison = 2026,
nation = "GBR"
),
Reiter(
id = 4,
vorname = "Stefan",
nachname = "Moser",
oepsNummer = "O-44332",
feiId = "10011111",
verein = "URFV Neumarkt/M.",
lizenzKlasse = "R2D2",
startkartAktiv = true,
startkartSaison = 2026,
nation = "AUT",
vereinsNummer = "4-001"
),
)
val funktionaere: SnapshotStateList<Funktionaer> = mutableStateListOf(
Funktionaer(
id = 1,
vorname = "Wolfgang",
nachname = "Schier",
richterNummer = "100123",
rollen = listOf("RICHTER"),
richterQualifikation = "G3",
qualifiziertFuerSparten = listOf("DRESSUR", "SPRINGEN"),
email = "wolfgang.schier@oeps.at",
vereinsNummer = "4-001"
),
Funktionaer(
id = 2,
vorname = "Alice",
nachname = "Schwab",
richterNummer = "100456",
rollen = listOf("RICHTER", "TBA"),
richterQualifikation = "INTERNATIONAL",
qualifiziertFuerSparten = listOf("DRESSUR"),
email = "alice.schwab@oeps.at",
vereinsNummer = "4-002"
),
Funktionaer(
id = 3,
vorname = "Dietmar",
nachname = "Gstöttner",
richterNummer = "100789",
rollen = listOf("PARCOURSBAUER"),
email = "dietmar.gstoettner@oeps.at",
vereinsNummer = "4-003"
),
)
val oepsStammdaten: List<Verein> = listOf(
Verein(1001, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."),
Verein(1002, "Pferdesportverein Linz", "V-OOE-0002", "Linz"),
Verein(1003, "Reitclub Ebelsberg", "V-OOE-0003", "Linz-Ebelsberg"),
Verein(1004, "Union Reitverein Gschwandt", "V-OOE-0004", "Gschwandt"),
Verein(1005, "Reitsportclub Gleisdorf", "V-ST-0005", "Gleisdorf"),
Verein(1006, "Pferdesportzentrum Stadl-Paura", "V-OOE-0006", "Stadl-Paura"),
Verein(
1001,
"Union Reit- und Fahrverein Neumarkt/M.",
"4-001",
ort = "Neumarkt/M.",
bundesland = "",
istVeranstalter = true
),
Verein(1002, "Pferdesportverein Linz", "4-002", ort = "Linz", bundesland = "", istVeranstalter = true),
Verein(1003, "Reitclub Ebelsberg", "4-003", ort = "Linz-Ebelsberg", bundesland = "", istVeranstalter = true),
Verein(1004, "Union Reitverein Gschwandt", "4-004", ort = "Gschwandt", bundesland = "", istVeranstalter = true),
Verein(1005, "Reitsportclub Gleisdorf", "5-005", ort = "Gleisdorf", bundesland = "ST", istVeranstalter = true),
Verein(
1006,
"Pferdesportzentrum Stadl-Paura",
"4-006",
ort = "Stadl-Paura",
bundesland = "",
istVeranstalter = true
),
)
val vereine: SnapshotStateList<Verein> = mutableStateListOf(
Verein(1, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."),
Verein(2, "Pferdesportverein Linz", "V-OOE-0002", "Linz"),
Verein(
1,
"Union Reit- und Fahrverein Neumarkt/M.",
"4-001",
ort = "Neumarkt/M.",
bundesland = "",
istVeranstalter = true
),
Verein(2, "Pferdesportverein Linz", "4-002", ort = "Linz", bundesland = "", istVeranstalter = true),
)
fun addVerein(name: String, oeps: String, ort: String): Long {
val id = (vereine.maxOfOrNull { it.id } ?: 0) + 1
vereine.add(Verein(id, name, oeps, ort))
vereine.add(Verein(id, name, oeps, ort = ort))
return id
}
@ -96,7 +313,7 @@ object StoreV2 {
datumVon = "2026-05-20",
datumBis = "2026-05-24",
status = "In Vorbereitung",
beschreibung = "Großes Reit-Event am Ebelsberger Schlosspark."
beschreibung = "Große Reitsport-Veranstaltung am Ebelsberger Schlosspark."
)
)
TurnierStoreV2.add(

View File

@ -28,37 +28,81 @@ import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VeranstaltungenUebersichtV2(
onEventOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
onNewEvent: () -> Unit
fun VeranstaltungVerwaltungV2(
onVeranstaltungOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
onNewVeranstaltung: () -> Unit,
onNavigateToPferde: () -> Unit,
onNavigateToReiter: () -> Unit,
onNavigateToVereine: () -> Unit,
onNavigateToFunktionaere: () -> Unit,
onNavigateToVeranstalter: () -> Unit,
onNavigateToZnsImport: () -> Unit
) {
DesktopThemeV2 {
val allEvents = remember { StoreV2.allEvents() }
val allVeranstaltungen = remember { StoreV2.allEvents() }
val vereine = StoreV2.vereine
var searchQuery by remember { mutableStateOf("") }
var selectedStatus by remember { mutableStateOf<String?>(null) }
val availableStatuses = remember(allEvents) { allEvents.map { it.status }.distinct().sorted() }
val availableStatuses = remember(allVeranstaltungen) { allVeranstaltungen.map { it.status }.distinct().sorted() }
val filteredEvents = remember(allEvents, searchQuery, selectedStatus) {
allEvents.filter { event ->
val verein = vereine.find { it.id == event.veranstalterId }
val matchesSearch = event.titel.contains(searchQuery, ignoreCase = true) ||
val filteredVeranstaltungen = remember(allVeranstaltungen, searchQuery, selectedStatus) {
allVeranstaltungen.filter { veranstaltung ->
val verein = vereine.find { it.id == veranstaltung.veranstalterId }
val matchesSearch = veranstaltung.titel.contains(searchQuery, ignoreCase = true) ||
(verein?.name?.contains(searchQuery, ignoreCase = true) ?: false)
val matchesStatus = selectedStatus == null || event.status == selectedStatus
val matchesStatus = selectedStatus == null || veranstaltung.status == selectedStatus
matchesSearch && matchesStatus
}.sortedByDescending { it.datumVon }
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Navigation Toolbar (Top)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AssistChip(
onClick = onNavigateToPferde,
label = { Text("Pferde") },
leadingIcon = { Icon(Icons.Default.Pets, null) })
AssistChip(
onClick = onNavigateToReiter,
label = { Text("Reiter") },
leadingIcon = { Icon(Icons.Default.Person, null) })
AssistChip(
onClick = onNavigateToVereine,
label = { Text("Vereine") },
leadingIcon = { Icon(Icons.Default.Home, null) })
AssistChip(
onClick = onNavigateToFunktionaere,
label = { Text("Funktionäre") },
leadingIcon = { Icon(Icons.Default.Badge, null) })
AssistChip(
onClick = onNavigateToVeranstalter,
label = { Text("Veranstalter") },
leadingIcon = { Icon(Icons.Default.Business, null) })
VerticalDivider(Modifier.height(32.dp).padding(horizontal = 4.dp))
AssistChip(
onClick = onNavigateToZnsImport,
label = { Text("ZNS Importer") },
leadingIcon = { Icon(Icons.Default.CloudDownload, null) },
colors = AssistChipDefaults.assistChipColors(
labelColor = MaterialTheme.colorScheme.primary,
leadingIconContentColor = MaterialTheme.colorScheme.primary
)
)
}
// Header
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Alle Veranstaltungen", style = MaterialTheme.typography.headlineMedium)
Button(onClick = onNewEvent) {
Text("Veranstaltung-Verwaltung", style = MaterialTheme.typography.headlineMedium)
Button(onClick = onNewVeranstaltung) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Neue Veranstaltung")
@ -109,7 +153,7 @@ fun VeranstaltungenUebersichtV2(
}
}
if (filteredEvents.isEmpty()) {
if (filteredVeranstaltungen.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
if (searchQuery.isEmpty() && selectedStatus == null) "Keine Veranstaltungen gefunden."
@ -119,23 +163,24 @@ fun VeranstaltungenUebersichtV2(
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(filteredEvents) { event ->
val verein = vereine.find { it.id == event.veranstalterId }
items(filteredVeranstaltungen) { veranstaltung ->
val verein = vereine.find { it.id == veranstaltung.veranstalterId }
Card(
modifier = Modifier.fillMaxWidth().clickable { onEventOpen(event.veranstalterId, event.id) },
modifier = Modifier.fillMaxWidth()
.clickable { onVeranstaltungOpen(veranstaltung.veranstalterId, veranstaltung.id) },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(event.titel, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(veranstaltung.titel, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"${verein?.name ?: "Unbekannter Verein"} | ${event.datumVon} bis ${event.datumBis ?: ""}",
"${verein?.name ?: "Unbekannter Verein"} | ${veranstaltung.datumVon} bis ${veranstaltung.datumBis ?: ""}",
style = MaterialTheme.typography.bodySmall
)
if (event.beschreibung.isNotEmpty()) {
if (veranstaltung.beschreibung.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
event.beschreibung,
veranstaltung.beschreibung,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
color = Color.DarkGray
@ -147,7 +192,7 @@ fun VeranstaltungenUebersichtV2(
shape = MaterialTheme.shapes.small
) {
Text(
event.status,
veranstaltung.status,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
@ -213,7 +258,7 @@ fun VeranstalterAnlegenWizard(
if (searchQuery.length < 2) emptyList()
else StoreV2.oepsStammdaten.filter {
it.name.contains(searchQuery, ignoreCase = true) ||
it.ort.contains(searchQuery, ignoreCase = true) ||
(it.ort?.contains(searchQuery, ignoreCase = true) ?: false) ||
it.oepsNummer.contains(searchQuery, ignoreCase = true)
}
}
@ -223,11 +268,11 @@ fun VeranstalterAnlegenWizard(
items(results) { v ->
ListItem(
headlineContent = { Text(v.name) },
supportingContent = { Text("${v.ort} | ${v.oepsNummer}") },
supportingContent = { Text("${v.ort ?: ""} | ${v.oepsNummer}") },
modifier = Modifier.clickable {
name = v.name
oeps = v.oepsNummer
ort = v.ort
ort = v.ort ?: ""
step = 2
}
)
@ -409,7 +454,7 @@ fun VeranstaltungKonfigV2(
var search by remember { mutableStateOf("") }
val filteredVereine = remember(search) {
StoreV2.vereine.filter {
it.name.contains(search, ignoreCase = true) || it.ort.contains(search, ignoreCase = true)
it.name.contains(search, ignoreCase = true) || (it.ort?.contains(search, ignoreCase = true) ?: false)
}
}
@ -444,7 +489,7 @@ fun VeranstaltungKonfigV2(
) {
Column(Modifier.weight(1f)) {
Text(verein.name, fontWeight = FontWeight.Bold)
Text("${verein.ort} | ${verein.oepsNummer}", style = MaterialTheme.typography.bodySmall)
Text("${verein.ort ?: ""} | ${verein.oepsNummer}", style = MaterialTheme.typography.bodySmall)
}
if (isSelected) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
@ -700,12 +745,13 @@ object TurnierStoreV2 {
}
@Composable
fun VeranstaltungUebersichtV2(
fun VeranstaltungProfilScreen(
veranstalterId: Long,
veranstaltungId: Long,
onBack: () -> Unit,
onTurnierNeu: () -> Unit,
onTurnierOpen: (Long) -> Unit,
onNavigateToVeranstalterProfil: (Long) -> Unit,
) {
DesktopThemeV2 {
val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId }
@ -732,6 +778,15 @@ fun VeranstaltungUebersichtV2(
}
}
Spacer(Modifier.weight(1f))
AssistChip(
onClick = { onNavigateToVeranstalterProfil(veranstalterId) },
label = { Text("Veranstalter-Profil") },
leadingIcon = { Icon(Icons.Default.Business, contentDescription = null, modifier = Modifier.size(18.dp)) }
)
Spacer(Modifier.width(8.dp))
ElevatedButton(
onClick = onTurnierNeu,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
@ -1244,7 +1299,7 @@ private fun Step2Sparten(
isError = !isDateValid && tVon != null && vVon != null && tVon.isBefore(vVon),
supportingText = {
if (!isDateValid && tVon != null && vVon != null && tVon.isBefore(vVon)) {
Text("Muss innerhalb der Veranstaltung liegen (${veranstaltung?.datumVon})")
Text("Muss innerhalb der Veranstaltung liegen (${veranstaltung.datumVon})")
}
}
)
@ -1264,7 +1319,7 @@ private fun Step2Sparten(
))),
supportingText = {
if (!isDateValid && tBis != null) {
if (vBis != null && tBis.isAfter(vBis)) Text("Darf nicht nach der Veranstaltung enden (${veranstaltung?.datumBis})")
if (vBis != null && tBis.isAfter(vBis)) Text("Darf nicht nach der Veranstaltung enden (${veranstaltung.datumBis})")
else if (tVon != null && tBis.isBefore(tVon)) Text("Darf nicht vor dem Startdatum liegen")
}
}