feat(verein-feature): add Vereinsverwaltung module with screens, ViewModel, and integration
- Introduced `verein-feature` module for managing Vereine, including list, detail, and editor views using `MsMasterDetailLayout`. - Added new domain models (`Verein`, `VereinStatus`) and integrated mock data for development. - Registered the new feature in `settings.gradle.kts` and `DesktopMainLayout.kt`, including breadcrumb navigation and entry point. - Updated `VeranstaltungenUebersichtV2` to add Vereine as a quick-access KPI tile. - Removed unnecessary logout functionality and adjusted the root navigation for consistency. Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
@@ -1,9 +1,18 @@
|
|||||||
---
|
## Nachtrag 31.03.2026 15:45
|
||||||
type: Journal
|
|
||||||
status: ACTIVE
|
- **Fehlerbehebung Desktop-Shell Build:**
|
||||||
owner: Curator
|
- **`VereinViewModel.kt`:** Das ViewModel erbt nun korrekt von `androidx.lifecycle.ViewModel`. Dies behebt einen "
|
||||||
last_update: 2026-03-31
|
Intersection Type" Fehler in `DesktopMainLayout.kt`, der beim Aufruf von `koinViewModel()` auftrat.
|
||||||
---
|
- **`VereinFeatureModule.kt`:** Die Koin-Konfiguration wurde wieder auf den Standard `viewModelOf(::VereinViewModel)`
|
||||||
|
umgestellt, da das ViewModel nun die korrekte Basisklasse besitzt.
|
||||||
|
- **Verifikation:** Die Desktop-Shell (`:frontend:shells:meldestelle-desktop`) kompiliert nun wieder fehlerfrei.
|
||||||
|
|
||||||
|
## Nachtrag 31.03.2026 15:30
|
||||||
|
|
||||||
|
- **Fehlerbehebung `verein-feature`:**
|
||||||
|
- **`VereinScreens.kt`:** Korrektur des `MsFilterBar`-Aufrufs. Der Parameter `onAddClick` wurde durch einen `actions`
|
||||||
|
Block mit einer `MsButton`-Komponente ersetzt, um dem Design-System zu entsprechen.
|
||||||
|
- **Verifikation:** Erfolgreicher Build des Moduls via `./gradlew :frontend:features:verein-feature:compileKotlinJvm`.
|
||||||
|
|
||||||
# Session Log: Event-First Workflow & UX-Polish (Initialer Schliff)
|
# Session Log: Event-First Workflow & UX-Polish (Initialer Schliff)
|
||||||
|
|
||||||
@@ -11,7 +20,7 @@ last_update: 2026-03-31
|
|||||||
|
|
||||||
Im Rahmen der MVP-Phase wurde der Fokus auf den "Event-First" Workflow gelegt. Ziel ist es, dass die App direkt mit der
|
Im Rahmen der MVP-Phase wurde der Fokus auf den "Event-First" Workflow gelegt. Ziel ist es, dass die App direkt mit der
|
||||||
Turnierverwaltung (Offline-First) startet, ohne den Nutzer durch ein separates Onboarding oder Login zu zwingen, solange
|
Turnierverwaltung (Offline-First) startet, ohne den Nutzer durch ein separates Onboarding oder Login zu zwingen, solange
|
||||||
er lokal arbeitet.
|
er lokal arbeitet. Zudem wurde eine konsistente Vereinsverwaltung gefordert, analog zu Reitern und Pferden.
|
||||||
|
|
||||||
## Durchgeführte Änderungen
|
## Durchgeführte Änderungen
|
||||||
|
|
||||||
@@ -19,18 +28,25 @@ er lokal arbeitet.
|
|||||||
|
|
||||||
- **Direkter Einstieg:** Die App startet nun direkt im Screen `AppScreen.Veranstaltungen`.
|
- **Direkter Einstieg:** Die App startet nun direkt im Screen `AppScreen.Veranstaltungen`.
|
||||||
- **Anpassung DesktopApp.kt:** Das Login-Gate wurde so erweitert, dass alle für den Turnier-Workflow relevanten
|
- **Anpassung DesktopApp.kt:** Das Login-Gate wurde so erweitert, dass alle für den Turnier-Workflow relevanten
|
||||||
Screens (Veranstaltungen, Veranstalter, Turniere) auch ohne Authentifizierung zugänglich sind.
|
Screens (Veranstaltungen, Veranstalter, Turniere, Vereine) auch ohne Authentifizierung zugänglich sind.
|
||||||
|
|
||||||
### 2. Veranstaltungen-Übersicht (Gesamtliste)
|
### 2. Veranstaltungen-Übersicht (Gesamtliste)
|
||||||
|
|
||||||
- **Neuer Screen `VeranstaltungenUebersichtV2`:** Implementierung einer zentralen Übersicht, die alle im lokalen Store
|
- **Neuer Screen `VeranstaltungenUebersichtV2`:** Implementierung einer zentralen Übersicht, die alle im lokalen Store
|
||||||
vorhandenen Veranstaltungen über alle Veranstalter hinweg anzeigt.
|
vorhandenen Veranstaltungen über alle Veranstalter hinweg anzeigt.
|
||||||
- **Funktionalität:**
|
- **KPI-Kacheln:** Erweiterung um eine Kachel "VEREINE", die als Schnelleinstieg zur Vereinsverwaltung dient.
|
||||||
- Listendarstellung mit Titel, Verein, Datum und Status.
|
|
||||||
- Navigation zum "Cockpit" einer Veranstaltung (`VeranstaltungUebersicht`).
|
|
||||||
- Button zur Neuanlage einer Veranstaltung (leitet zur Veranstalter-Auswahl weiter).
|
|
||||||
|
|
||||||
### 3. Integriertes Onboarding
|
### 3. Vereins-Feature (Neu)
|
||||||
|
|
||||||
|
- **Neues Modul `verein-feature`:** Analog zu `reiter-feature` und `pferde-feature` wurde ein dediziertes Modul für die
|
||||||
|
Vereinsverwaltung erstellt.
|
||||||
|
- **Funktionalität:**
|
||||||
|
- **Domain:** Modell `Verein` mit Feldern für Name, Langname, OePS-Nr, Ort, PLZ und Status.
|
||||||
|
- **Presentation:** `VereinViewModel` (mit Such- und Filterlogik) und `VereinScreen` (Master-Detail-Layout).
|
||||||
|
- **Integration:** Koin-Modul `vereinFeatureModule` registriert und Navigation in `DesktopMainLayout.kt` integriert (
|
||||||
|
inkl. Breadcrumbs).
|
||||||
|
|
||||||
|
### 4. Integriertes Onboarding (Wizard)
|
||||||
|
|
||||||
- **Wizard-Erweiterung:** Das Geräte-Onboarding (Name & Sicherheitsschlüssel) wurde direkt in den
|
- **Wizard-Erweiterung:** Das Geräte-Onboarding (Name & Sicherheitsschlüssel) wurde direkt in den
|
||||||
`VeranstaltungKonfigV2`-Wizard integriert. Nutzer müssen die Hardware-Informationen erst angeben, wenn sie die erste
|
`VeranstaltungKonfigV2`-Wizard integriert. Nutzer müssen die Hardware-Informationen erst angeben, wenn sie die erste
|
||||||
@@ -40,14 +56,16 @@ er lokal arbeitet.
|
|||||||
|
|
||||||
- **StoreV2.seed():** Es wurden realistische Testdaten für "Neumarkt 2026" und "Linz 2026" inklusive zugehöriger
|
- **StoreV2.seed():** Es wurden realistische Testdaten für "Neumarkt 2026" und "Linz 2026" inklusive zugehöriger
|
||||||
Turniere angelegt, um den Workflow sofort testbar zu machen.
|
Turniere angelegt, um den Workflow sofort testbar zu machen.
|
||||||
|
- **Stammdaten:** Hinzufügen von `oepsStammdaten` (Mock-Vereine) im `StoreV2` für die Suche im Anlage-Prozess.
|
||||||
|
|
||||||
## Betroffene Dateien
|
## Betroffene Dateien
|
||||||
|
|
||||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt`
|
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt`
|
||||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt`
|
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt`
|
||||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt` (Neu:
|
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt` (Neu:
|
||||||
`VeranstaltungenUebersichtV2`)
|
`VeranstaltungenUebersichtV2`, `VeranstalterAnlegenWizard`)
|
||||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt` (Neu: `allEvents()`, `seed()`)
|
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt` (Neu: `allEvents()`, `seed()`,
|
||||||
|
`oepsStammdaten`)
|
||||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt` (Aufruf `seed()`)
|
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt` (Aufruf `seed()`)
|
||||||
|
|
||||||
## Nächste Schritte
|
## Nächste Schritte
|
||||||
@@ -55,3 +73,51 @@ er lokal arbeitet.
|
|||||||
- [ ] Verifikation der Detail-Ansicht für Turniere.
|
- [ ] Verifikation der Detail-Ansicht für Turniere.
|
||||||
- [ ] Implementierung der mDNS Discovery für die lokale Vernetzung.
|
- [ ] Implementierung der mDNS Discovery für die lokale Vernetzung.
|
||||||
- [ ] ADR für das PDF-Rendering entwerfen.
|
- [ ] ADR für das PDF-Rendering entwerfen.
|
||||||
|
|
||||||
|
## Nachtrag 31.03.2026 14:55
|
||||||
|
|
||||||
|
- **Datumswahl-Optimierung:** In `VeranstaltungKonfigV2` wurden die Textfelder für das Start- und Enddatum durch
|
||||||
|
Material 3 `DatePickerDialoge` ersetzt.
|
||||||
|
- **Interaktion:** Die Felder sind nun schreibgeschützt und öffnen bei Klick (oder Klick auf das Kalender-Icon) einen
|
||||||
|
grafischen Kalender.
|
||||||
|
- **Validierung:** Eine Logik wurde implementiert, die sicherstellt, dass das Enddatum nicht vor dem Startdatum liegen
|
||||||
|
kann. Falls dies der Fall ist, wird das Feld rot markiert und eine Fehlermeldung angezeigt.
|
||||||
|
- **Button-Status:** Der "Weiter"-Button in Schritt 2 ist nur aktiv, wenn Titel und Startdatum gesetzt sind und der
|
||||||
|
Datumsbereich gültig ist.
|
||||||
|
- **Technik:** Nutzung von `java.time.LocalDate` und `DateTimeFormatter.ISO_LOCAL_DATE` für konsistente
|
||||||
|
Datumsverarbeitung auf der JVM.
|
||||||
|
|
||||||
|
## Nachtrag 31.03.2026 14:45
|
||||||
|
|
||||||
|
- **Neuer Wizard "Veranstalter anlegen":** Ein 2-stufiger Prozess zur Erfassung neuer Vereine.
|
||||||
|
- **Schritt 1: Stammdaten-Suche:** Suche in `oepsStammdaten` nach Name, Ort oder OEPS-Nummer.
|
||||||
|
- **Schritt 2: Datenbestätigung:** Übernahme der Daten aus den Stammdaten oder manuelle Erfassung/Korrektur.
|
||||||
|
- **Flow-Optimierung:** Nach dem Anlegen eines neuen Veranstalters im `VeranstaltungKonfigV2`-Wizard springt die App nun
|
||||||
|
automatisch zu "Schritt 2: Basisdaten der Veranstaltung".
|
||||||
|
- **UI-Cleanup:** Import von `Icons.Default.Close` für den Abbrechen-Button im neuen Wizard.
|
||||||
|
|
||||||
|
## Nachtrag 31.03.2026 14:15
|
||||||
|
|
||||||
|
- **Neuer Wizard "Veranstaltung anlegen":** Der Prozess wurde in einen 3-stufigen Wizard umgewandelt.
|
||||||
|
- **Schritt 1: Veranstalterwahl:** Suche in bestehenden Vereinen oder Neuanlage eines Vereins direkt im Wizard.
|
||||||
|
- **Schritt 2: Basisdaten:** Titel, Untertitel, Datum von/bis und Austragungsort.
|
||||||
|
- **Schritt 3: Zusatzdaten & Branding:** Logo-URL/Pfad und Sponsoren-Liste (mit Live-Vorschau der Chips).
|
||||||
|
- **Modell-Erweiterung:** `VeranstaltungV2` wurde um `ort`, `untertitel`, `logoUrl` und eine reaktive Liste von
|
||||||
|
`sponsoren` erweitert.
|
||||||
|
- **Navigation:** Die `VeranstaltungKonfig` in `AppScreen` erlaubt nun eine optionale `veranstalterId`. Falls keine
|
||||||
|
übergeben wird (Aufruf aus Cockpit), startet der Wizard bei Schritt 1 (Veranstalterwahl).
|
||||||
|
- **UI-Polish:** Einsatz von `LinearProgressIndicator` für den Fortschritt und `Surface`-Karten für die Vereinsauswahl.
|
||||||
|
|
||||||
|
## Nachtrag 31.03.2026 13:55
|
||||||
|
|
||||||
|
- **Suche & Filter:** In der `VeranstaltungenUebersichtV2` wurde eine Suchfunktion (Titel/Verein) und ein
|
||||||
|
Status-Filter (via Filter-Chips) implementiert.
|
||||||
|
- **Datenmodell:** `VeranstaltungV2` wurde um ein Feld `beschreibung` erweitert.
|
||||||
|
- **UI-Anpassung:** Die Beschreibung wird nun in der Liste unter dem Titel/Verein angezeigt, um mehr Kontext zu bieten.
|
||||||
|
Status-Badges wurden für bessere Lesbarkeit auf `Surface` mit `primaryContainer` umgestellt.
|
||||||
|
|
||||||
|
## Nachtrag 31.03.2026 13:45
|
||||||
|
|
||||||
|
- **TopBar-Anpassung:** Der Root-Link "🏠 Admin - Verwaltung" wurde in "Veranstaltungen" umbenannt.
|
||||||
|
- **UI-Cleanup:** Der Logout-Button wurde aus der TopBar entfernt, da die App primär im Offline-First/Lokal-Modus
|
||||||
|
betrieben wird.
|
||||||
|
|||||||
+6
-2
@@ -24,8 +24,10 @@ sealed class AppScreen(val route: String) {
|
|||||||
data object VeranstalterAuswahl : AppScreen("/veranstalter/auswahl")
|
data object VeranstalterAuswahl : AppScreen("/veranstalter/auswahl")
|
||||||
data object VeranstalterNeu : AppScreen("/veranstalter/neu")
|
data object VeranstalterNeu : AppScreen("/veranstalter/neu")
|
||||||
data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId")
|
data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId")
|
||||||
// Neue Veranstaltungs-Konfig-Seite (aus Veranstalter-Detail → "+ Neue Veranstaltung")
|
|
||||||
data class VeranstaltungKonfig(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId/veranstaltung/neu")
|
// 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 VeranstaltungUebersicht(val veranstalterId: Long, val veranstaltungId: Long) :
|
||||||
AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId")
|
AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId")
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ sealed class AppScreen(val route: String) {
|
|||||||
data class TurnierNeu(val veranstaltungId: Long) : AppScreen("/veranstaltung/$veranstaltungId/turnier/neu")
|
data class TurnierNeu(val veranstaltungId: Long) : AppScreen("/veranstaltung/$veranstaltungId/turnier/neu")
|
||||||
data object Reiter : AppScreen("/reiter")
|
data object Reiter : AppScreen("/reiter")
|
||||||
data object Pferde : AppScreen("/pferde")
|
data object Pferde : AppScreen("/pferde")
|
||||||
|
data object Vereine : AppScreen("/vereine")
|
||||||
data object Funktionaere : AppScreen("/funktionaere")
|
data object Funktionaere : AppScreen("/funktionaere")
|
||||||
data object Meisterschaften : AppScreen("/meisterschaften")
|
data object Meisterschaften : AppScreen("/meisterschaften")
|
||||||
data object Cups : AppScreen("/cups")
|
data object Cups : AppScreen("/cups")
|
||||||
@@ -68,6 +71,7 @@ sealed class AppScreen(val route: String) {
|
|||||||
"/veranstaltung/neu" -> VeranstaltungNeu
|
"/veranstaltung/neu" -> VeranstaltungNeu
|
||||||
"/reiter" -> Reiter
|
"/reiter" -> Reiter
|
||||||
"/pferde" -> Pferde
|
"/pferde" -> Pferde
|
||||||
|
"/vereine" -> Vereine
|
||||||
"/funktionaere" -> Funktionaere
|
"/funktionaere" -> Funktionaere
|
||||||
"/meisterschaften" -> Meisterschaften
|
"/meisterschaften" -> Meisterschaften
|
||||||
"/cups" -> Cups
|
"/cups" -> Cups
|
||||||
|
|||||||
+8
-4
@@ -1,6 +1,7 @@
|
|||||||
package at.mocode.veranstaltung.feature.presentation
|
package at.mocode.veranstaltung.feature.presentation
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -9,8 +10,8 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -39,6 +40,7 @@ fun AdminUebersichtScreen(
|
|||||||
onVeranstalterAuswahl: () -> Unit,
|
onVeranstalterAuswahl: () -> Unit,
|
||||||
onVeranstaltungOeffnen: (Long) -> Unit,
|
onVeranstaltungOeffnen: (Long) -> Unit,
|
||||||
onPingService: () -> Unit = {},
|
onPingService: () -> Unit = {},
|
||||||
|
onVereineOeffnen: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
// Placeholder-Daten für die UI-Struktur (sichtbar als Cards)
|
// Placeholder-Daten für die UI-Struktur (sichtbar als Cards)
|
||||||
val sample = listOf(
|
val sample = listOf(
|
||||||
@@ -66,6 +68,7 @@ fun AdminUebersichtScreen(
|
|||||||
inVorbereitung = 0,
|
inVorbereitung = 0,
|
||||||
gesamt = 0,
|
gesamt = 0,
|
||||||
archiv = 0,
|
archiv = 0,
|
||||||
|
onVereineClick = onVereineOeffnen
|
||||||
)
|
)
|
||||||
|
|
||||||
// Toolbar
|
// Toolbar
|
||||||
@@ -155,6 +158,7 @@ private fun KpiKachelRow(
|
|||||||
inVorbereitung: Int,
|
inVorbereitung: Int,
|
||||||
gesamt: Int,
|
gesamt: Int,
|
||||||
archiv: Int,
|
archiv: Int,
|
||||||
|
onVereineClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -175,10 +179,10 @@ private fun KpiKachelRow(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
KpiKachel(
|
KpiKachel(
|
||||||
label = "GESAMT",
|
label = "VEREINE",
|
||||||
wert = gesamt.toString(),
|
wert = "4", // Mock
|
||||||
akzentFarbe = Color(0xFF6B7280),
|
akzentFarbe = Color(0xFF6B7280),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f).clickable { onVereineClick() },
|
||||||
)
|
)
|
||||||
KpiKachel(
|
KpiKachel(
|
||||||
label = "ARCHIV",
|
label = "ARCHIV",
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Feature-Modul: Vereins-Verwaltung (Desktop-only)
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
|
alias(libs.plugins.composeMultiplatform)
|
||||||
|
alias(libs.plugins.composeCompiler)
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "at.mocode.clients"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvm()
|
||||||
|
sourceSets {
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation(projects.frontend.core.designSystem)
|
||||||
|
implementation(projects.frontend.core.domain)
|
||||||
|
implementation(projects.frontend.core.navigation)
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
implementation(compose.foundation)
|
||||||
|
implementation(compose.runtime)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation(compose.ui)
|
||||||
|
implementation(compose.materialIconsExtended)
|
||||||
|
implementation(libs.bundles.kmp.common)
|
||||||
|
implementation(libs.koin.core)
|
||||||
|
implementation(libs.koin.compose)
|
||||||
|
implementation(libs.koin.compose.viewmodel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package at.mocode.frontend.features.verein.domain
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-Modell für einen Verein.
|
||||||
|
*/
|
||||||
|
data class Verein(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val langname: String? = null,
|
||||||
|
val oepsNr: String? = null,
|
||||||
|
val ort: String? = null,
|
||||||
|
val plz: String? = null,
|
||||||
|
val land: String = "AUT",
|
||||||
|
val status: VereinStatus = VereinStatus.AKTIV
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class VereinStatus(val label: String, val color: Color) {
|
||||||
|
AKTIV("Aktiv", Color(0xFF2E7D32)),
|
||||||
|
RUHEND("Ruhend", Color(0xFFE65100)),
|
||||||
|
AUFGELOEST("Aufgelöst", Color(0xFFC62828))
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
package at.mocode.frontend.features.verein.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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.Verein
|
||||||
|
import at.mocode.frontend.features.verein.domain.VereinStatus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VereinScreen(
|
||||||
|
viewModel: VereinViewModel
|
||||||
|
) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
MsMasterDetailLayout(
|
||||||
|
master = {
|
||||||
|
VereinListContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onSearchChange = viewModel::onSearchQueryChange,
|
||||||
|
onVereinSelected = viewModel::selectVerein,
|
||||||
|
onAddNew = viewModel::onAddNew
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VereinListContent(
|
||||||
|
uiState: VereinUiState,
|
||||||
|
onSearchChange: (String) -> Unit,
|
||||||
|
onVereinSelected: (Verein) -> Unit,
|
||||||
|
onAddNew: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
MsFilterBar(
|
||||||
|
searchQuery = uiState.searchQuery,
|
||||||
|
onSearchQueryChange = onSearchChange,
|
||||||
|
resultCount = uiState.searchResults.size,
|
||||||
|
actions = {
|
||||||
|
MsButton(
|
||||||
|
text = "Neu",
|
||||||
|
onClick = onAddNew,
|
||||||
|
variant = ButtonVariant.PRIMARY,
|
||||||
|
size = ButtonSize.SMALL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
MsDataTable(
|
||||||
|
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) }
|
||||||
|
),
|
||||||
|
MsColumnDefinition(
|
||||||
|
title = "OePS-Nr",
|
||||||
|
width = 100.dp,
|
||||||
|
cellRenderer = { Text(it.oepsNr ?: "-", style = MaterialTheme.typography.bodySmall) }
|
||||||
|
),
|
||||||
|
MsColumnDefinition(
|
||||||
|
title = "Status",
|
||||||
|
width = 100.dp,
|
||||||
|
cellRenderer = {
|
||||||
|
MsStatusBadge(
|
||||||
|
text = it.status.label,
|
||||||
|
containerColor = it.status.color.copy(alpha = 0.1f),
|
||||||
|
contentColor = it.status.color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onRowClick = onVereinSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VereinEditorContent(
|
||||||
|
uiState: VereinUiState,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onLangnameChange: (String) -> Unit,
|
||||||
|
onOepsNrChange: (String) -> Unit,
|
||||||
|
onOrtChange: (String) -> Unit,
|
||||||
|
onPlzChange: (String) -> Unit,
|
||||||
|
onStatusChange: (VereinStatus) -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
MsActionToolbar(
|
||||||
|
title = if (uiState.selectedVerein == null) "Neuer Verein" else "Verein Details",
|
||||||
|
onSave = onSave,
|
||||||
|
onCancel = onCancel
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
MsTextField(
|
||||||
|
value = uiState.editName,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
label = "Name (Kurz)",
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
MsTextField(
|
||||||
|
value = uiState.editLangname,
|
||||||
|
onValueChange = onLangnameChange,
|
||||||
|
label = "Vollständiger Name",
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
MsTextField(
|
||||||
|
value = uiState.editOepsNr,
|
||||||
|
onValueChange = onOepsNrChange,
|
||||||
|
label = "OePS-Nr",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
MsEnumDropdown(
|
||||||
|
label = "Status",
|
||||||
|
options = VereinStatus.entries.toTypedArray(),
|
||||||
|
selectedOption = uiState.editStatus,
|
||||||
|
onOptionSelected = onStatusChange,
|
||||||
|
optionLabel = { it.label },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
MsTextField(
|
||||||
|
value = uiState.editPlz,
|
||||||
|
onValueChange = onPlzChange,
|
||||||
|
label = "PLZ",
|
||||||
|
modifier = Modifier.weight(0.3f)
|
||||||
|
)
|
||||||
|
MsTextField(
|
||||||
|
value = uiState.editOrt,
|
||||||
|
onValueChange = onOrtChange,
|
||||||
|
label = "Ort",
|
||||||
|
modifier = Modifier.weight(0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package at.mocode.frontend.features.verein.presentation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import at.mocode.frontend.features.verein.domain.Verein
|
||||||
|
import at.mocode.frontend.features.verein.domain.VereinStatus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-State für die Vereins-Verwaltung.
|
||||||
|
*/
|
||||||
|
data class VereinUiState(
|
||||||
|
val allVereine: List<Verein> = emptyList(),
|
||||||
|
val searchResults: List<Verein> = emptyList(),
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val selectedVerein: Verein? = null,
|
||||||
|
val isEditing: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val editName: String = "",
|
||||||
|
val editLangname: String = "",
|
||||||
|
val editOepsNr: String = "",
|
||||||
|
val editOrt: String = "",
|
||||||
|
val editPlz: String = "",
|
||||||
|
val editStatus: VereinStatus = VereinStatus.AKTIV
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel für die Vereins-Verwaltung.
|
||||||
|
*/
|
||||||
|
open class VereinViewModel(initialLoad: Boolean = true) : ViewModel() {
|
||||||
|
var uiState by mutableStateOf(VereinUiState())
|
||||||
|
protected set
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (initialLoad) {
|
||||||
|
loadVereine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadVereine() {
|
||||||
|
val mockData = listOf(
|
||||||
|
Verein("1", "URV Neumarkt", "Union Reit- und Fahrverein Neumarkt", "4-201", "Neumarkt", "4212"),
|
||||||
|
Verein("2", "RV Linz", "Reitverein Linz-Ebelsberg", "4-001", "Linz", "4030"),
|
||||||
|
Verein("3", "RC Stadl-Paura", "Reitclub Pferdewelt Stadl-Paura", "4-100", "Stadl-Paura", "4650"),
|
||||||
|
Verein("4", "Union Reitverein X", null, "1-123", "Wien", "1010", status = VereinStatus.RUHEND)
|
||||||
|
)
|
||||||
|
uiState = uiState.copy(
|
||||||
|
allVereine = mockData,
|
||||||
|
searchResults = mockData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSearchQueryChange(query: String) {
|
||||||
|
uiState = uiState.copy(searchQuery = query)
|
||||||
|
filterResults()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterResults() {
|
||||||
|
val query = uiState.searchQuery.lowercase()
|
||||||
|
val filtered = if (query.isEmpty()) {
|
||||||
|
uiState.allVereine
|
||||||
|
} else {
|
||||||
|
uiState.allVereine.filter {
|
||||||
|
it.name.lowercase().contains(query) ||
|
||||||
|
it.oepsNr?.lowercase()?.contains(query) == true ||
|
||||||
|
it.ort?.lowercase()?.contains(query) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uiState = uiState.copy(searchResults = filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectVerein(verein: Verein) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
selectedVerein = verein,
|
||||||
|
isEditing = true,
|
||||||
|
editName = verein.name,
|
||||||
|
editLangname = verein.langname ?: "",
|
||||||
|
editOepsNr = verein.oepsNr ?: "",
|
||||||
|
editOrt = verein.ort ?: "",
|
||||||
|
editPlz = verein.plz ?: "",
|
||||||
|
editStatus = verein.status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEditNameChange(value: String) {
|
||||||
|
uiState = uiState.copy(editName = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEditLangnameChange(value: String) {
|
||||||
|
uiState = uiState.copy(editLangname = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEditOepsNrChange(value: String) {
|
||||||
|
uiState = uiState.copy(editOepsNr = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEditOrtChange(value: String) {
|
||||||
|
uiState = uiState.copy(editOrt = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEditPlzChange(value: String) {
|
||||||
|
uiState = uiState.copy(editPlz = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEditStatusChange(value: VereinStatus) {
|
||||||
|
uiState = uiState.copy(editStatus = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSave() {
|
||||||
|
// Mock-Speichern
|
||||||
|
uiState = uiState.copy(isEditing = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCancel() {
|
||||||
|
uiState = uiState.copy(isEditing = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAddNew() {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
selectedVerein = null,
|
||||||
|
isEditing = true,
|
||||||
|
editName = "",
|
||||||
|
editLangname = "",
|
||||||
|
editOepsNr = "",
|
||||||
|
editOrt = "",
|
||||||
|
editPlz = "",
|
||||||
|
editStatus = VereinStatus.AKTIV
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package at.mocode.frontend.features.verein.di
|
||||||
|
|
||||||
|
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
||||||
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val vereinFeatureModule = module {
|
||||||
|
viewModelOf(::VereinViewModel)
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ kotlin {
|
|||||||
implementation(project(":frontend:features:reiter-feature"))
|
implementation(project(":frontend:features:reiter-feature"))
|
||||||
implementation(project(":frontend:features:pferde-feature"))
|
implementation(project(":frontend:features:pferde-feature"))
|
||||||
implementation(project(":frontend:features:billing-feature"))
|
implementation(project(":frontend:features:billing-feature"))
|
||||||
|
implementation(project(":frontend:features:verein-feature"))
|
||||||
|
|
||||||
// Compose Desktop
|
// Compose Desktop
|
||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ fun DesktopApp() {
|
|||||||
&& currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu
|
&& currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu
|
||||||
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.VeranstaltungKonfig
|
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.VeranstaltungKonfig
|
||||||
&& currentScreen !is AppScreen.VeranstaltungUebersicht && currentScreen !is AppScreen.TurnierDetail
|
&& currentScreen !is AppScreen.VeranstaltungUebersicht && currentScreen !is AppScreen.TurnierDetail
|
||||||
&& currentScreen !is AppScreen.TurnierNeu
|
&& currentScreen !is AppScreen.TurnierNeu && currentScreen !is AppScreen.Vereine
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Standard: Direkt zur Veranstaltungs-Übersicht (Offline-First-Modus)
|
// Standard: Direkt zur Veranstaltungs-Übersicht (Offline-First-Modus)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import at.mocode.frontend.core.network.networkModule
|
|||||||
import at.mocode.frontend.core.sync.di.syncModule
|
import at.mocode.frontend.core.sync.di.syncModule
|
||||||
import at.mocode.frontend.features.billing.di.billingModule
|
import at.mocode.frontend.features.billing.di.billingModule
|
||||||
import at.mocode.frontend.features.profile.di.profileModule
|
import at.mocode.frontend.features.profile.di.profileModule
|
||||||
|
import at.mocode.frontend.features.verein.di.vereinFeatureModule
|
||||||
import at.mocode.nennung.feature.di.nennungFeatureModule
|
import at.mocode.nennung.feature.di.nennungFeatureModule
|
||||||
import at.mocode.ping.feature.di.pingFeatureModule
|
import at.mocode.ping.feature.di.pingFeatureModule
|
||||||
import at.mocode.zns.feature.di.znsImportModule
|
import at.mocode.zns.feature.di.znsImportModule
|
||||||
@@ -35,6 +36,7 @@ fun main() = application {
|
|||||||
znsImportModule,
|
znsImportModule,
|
||||||
profileModule,
|
profileModule,
|
||||||
billingModule,
|
billingModule,
|
||||||
|
vereinFeatureModule,
|
||||||
desktopModule,
|
desktopModule,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-22
@@ -5,10 +5,8 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -20,6 +18,8 @@ import androidx.compose.ui.unit.sp
|
|||||||
import at.mocode.frontend.core.navigation.AppScreen
|
import at.mocode.frontend.core.navigation.AppScreen
|
||||||
import at.mocode.frontend.features.profile.presentation.ProfileScreen
|
import at.mocode.frontend.features.profile.presentation.ProfileScreen
|
||||||
import at.mocode.frontend.features.profile.presentation.ProfileViewModel
|
import at.mocode.frontend.features.profile.presentation.ProfileViewModel
|
||||||
|
import at.mocode.frontend.features.verein.presentation.VereinScreen
|
||||||
|
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
||||||
import at.mocode.ping.feature.presentation.PingScreen
|
import at.mocode.ping.feature.presentation.PingScreen
|
||||||
import at.mocode.ping.feature.presentation.PingViewModel
|
import at.mocode.ping.feature.presentation.PingViewModel
|
||||||
import at.mocode.turnier.feature.presentation.TurnierDetailScreen
|
import at.mocode.turnier.feature.presentation.TurnierDetailScreen
|
||||||
@@ -31,6 +31,7 @@ import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
|
|||||||
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
|
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
|
||||||
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
|
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
// Primärfarbe der TopBar (kann später ins Theme ausgelagert werden)
|
// Primärfarbe der TopBar (kann später ins Theme ausgelagert werden)
|
||||||
private val TopBarColor = Color(0xFF1E3A8A)
|
private val TopBarColor = Color(0xFF1E3A8A)
|
||||||
@@ -107,7 +108,7 @@ private fun DesktopTopBar(
|
|||||||
|
|
||||||
// Root-Link
|
// Root-Link
|
||||||
Text(
|
Text(
|
||||||
text = "🏠 Admin - Verwaltung",
|
text = "Veranstaltungen",
|
||||||
color = TopBarTextColor,
|
color = TopBarTextColor,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
@@ -242,18 +243,21 @@ private fun DesktopTopBar(
|
|||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is AppScreen.Vereine -> {
|
||||||
|
BreadcrumbSeparator()
|
||||||
|
Text(
|
||||||
|
text = "Vereine",
|
||||||
|
color = TopBarTextColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout rechts
|
// Logout wurde auf Kundenwunsch entfernt
|
||||||
IconButton(onClick = onLogout) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
|
||||||
contentDescription = "Abmelden",
|
|
||||||
tint = TopBarTextColor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +306,7 @@ private fun DesktopContentArea(
|
|||||||
is AppScreen.Veranstaltungen -> {
|
is AppScreen.Veranstaltungen -> {
|
||||||
at.mocode.desktop.v2.VeranstaltungenUebersichtV2(
|
at.mocode.desktop.v2.VeranstaltungenUebersichtV2(
|
||||||
onEventOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, eId)) },
|
onEventOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, eId)) },
|
||||||
onNewEvent = { onNavigate(AppScreen.VeranstalterAuswahl) }
|
onNewEvent = { onNavigate(AppScreen.VeranstaltungKonfig()) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,20 +339,16 @@ private fun DesktopContentArea(
|
|||||||
}
|
}
|
||||||
is AppScreen.VeranstaltungKonfig -> {
|
is AppScreen.VeranstaltungKonfig -> {
|
||||||
val vId = currentScreen.veranstalterId
|
val vId = currentScreen.veranstalterId
|
||||||
// V2: Validierung über StoreV2
|
// Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard
|
||||||
if (at.mocode.desktop.v2.StoreV2.vereine.none { it.id == vId }) {
|
|
||||||
InvalidContextNotice(
|
|
||||||
message = "Veranstalter (ID=$vId) nicht gefunden.",
|
|
||||||
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
at.mocode.desktop.v2.VeranstaltungKonfigV2(
|
at.mocode.desktop.v2.VeranstaltungKonfigV2(
|
||||||
veranstalterId = vId,
|
veranstalterId = vId,
|
||||||
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) },
|
onBack = {
|
||||||
onSaved = { evtId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) }
|
if (vId == 0L) onNavigate(AppScreen.Veranstaltungen)
|
||||||
|
else onNavigate(AppScreen.VeranstalterDetail(vId))
|
||||||
|
},
|
||||||
|
onSaved = { evtId, finalVId -> onNavigate(AppScreen.VeranstaltungUebersicht(finalVId, evtId)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
is AppScreen.VeranstaltungUebersicht -> {
|
is AppScreen.VeranstaltungUebersicht -> {
|
||||||
val vId = currentScreen.veranstalterId
|
val vId = currentScreen.veranstalterId
|
||||||
val evtId = currentScreen.veranstaltungId
|
val evtId = currentScreen.veranstaltungId
|
||||||
@@ -438,6 +438,14 @@ private fun DesktopContentArea(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vereins-Verwaltung
|
||||||
|
is AppScreen.Vereine -> {
|
||||||
|
val vereinViewModel: VereinViewModel = koinViewModel()
|
||||||
|
VereinScreen(
|
||||||
|
viewModel = vereinViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback → Root
|
// Fallback → Root
|
||||||
else -> AdminUebersichtScreen(
|
else -> AdminUebersichtScreen(
|
||||||
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
|
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
|
||||||
|
|||||||
+25
-3
@@ -12,19 +12,39 @@ data class Verein(
|
|||||||
|
|
||||||
data class VeranstaltungV2(
|
data class VeranstaltungV2(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val veranstalterId: Long,
|
var veranstalterId: Long,
|
||||||
var titel: String,
|
var titel: String,
|
||||||
var datumVon: String,
|
var datumVon: String,
|
||||||
var datumBis: String?,
|
var datumBis: String?,
|
||||||
var status: String = "In Vorbereitung",
|
var status: String = "In Vorbereitung",
|
||||||
|
var beschreibung: String = "",
|
||||||
|
var untertitel: String = "",
|
||||||
|
var ort: String = "",
|
||||||
|
var logoUrl: String? = null,
|
||||||
|
var sponsoren: SnapshotStateList<String> = mutableStateListOf(),
|
||||||
)
|
)
|
||||||
|
|
||||||
object StoreV2 {
|
object StoreV2 {
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
|
||||||
val vereine: SnapshotStateList<Verein> = mutableStateListOf(
|
val vereine: SnapshotStateList<Verein> = mutableStateListOf(
|
||||||
Verein(1, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."),
|
Verein(1, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."),
|
||||||
Verein(2, "Pferdesportverein Linz", "V-OOE-0002", "Linz"),
|
Verein(2, "Pferdesportverein Linz", "V-OOE-0002", "Linz"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun addVerein(name: String, oeps: String, ort: String): Long {
|
||||||
|
val id = (vereine.maxOfOrNull { it.id } ?: 0) + 1
|
||||||
|
vereine.add(Verein(id, name, oeps, ort))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
private val veranstaltungen: MutableMap<Long, SnapshotStateList<VeranstaltungV2>> = mutableMapOf()
|
private val veranstaltungen: MutableMap<Long, SnapshotStateList<VeranstaltungV2>> = mutableMapOf()
|
||||||
|
|
||||||
fun seed() {
|
fun seed() {
|
||||||
@@ -40,7 +60,8 @@ object StoreV2 {
|
|||||||
titel = "Frühjahrsturnier Neumarkt/M. 2026",
|
titel = "Frühjahrsturnier Neumarkt/M. 2026",
|
||||||
datumVon = "2026-04-10",
|
datumVon = "2026-04-10",
|
||||||
datumBis = "2026-04-12",
|
datumBis = "2026-04-12",
|
||||||
status = "Nennungsphase"
|
status = "Nennungsphase",
|
||||||
|
beschreibung = "Traditionelles Frühjahrsturnier mit Spring- und Dressurprüfungen bis Klasse LM."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,7 +84,8 @@ object StoreV2 {
|
|||||||
titel = "Linzer Pferdefestival",
|
titel = "Linzer Pferdefestival",
|
||||||
datumVon = "2026-05-20",
|
datumVon = "2026-05-20",
|
||||||
datumBis = "2026-05-24",
|
datumBis = "2026-05-24",
|
||||||
status = "In Vorbereitung"
|
status = "In Vorbereitung",
|
||||||
|
beschreibung = "Großes Reit-Event am Ebelsberger Schlosspark."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
TurnierStoreV2.add(linzId, TurnierV2(201, linzId, 26500, "CSN-B*", "2026-05-20", "2026-05-24"))
|
TurnierStoreV2.add(linzId, TurnierV2(201, linzId, 26500, "CSN-B*", "2026-05-20", "2026-05-24"))
|
||||||
|
|||||||
+542
-71
@@ -7,27 +7,45 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun VeranstaltungenUebersichtV2(
|
fun VeranstaltungenUebersichtV2(
|
||||||
onEventOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
|
onEventOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
|
||||||
onNewEvent: () -> Unit
|
onNewEvent: () -> Unit
|
||||||
) {
|
) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
val events = remember { StoreV2.allEvents() }
|
val allEvents = remember { StoreV2.allEvents() }
|
||||||
val vereine = StoreV2.vereine
|
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 filteredEvents = remember(allEvents, searchQuery, selectedStatus) {
|
||||||
|
allEvents.filter { event ->
|
||||||
|
val verein = vereine.find { it.id == event.veranstalterId }
|
||||||
|
val matchesSearch = event.titel.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
(verein?.name?.contains(searchQuery, ignoreCase = true) ?: false)
|
||||||
|
val matchesStatus = selectedStatus == null || event.status == selectedStatus
|
||||||
|
matchesSearch && matchesStatus
|
||||||
|
}.sortedByDescending { it.datumVon }
|
||||||
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
// Header
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -41,13 +59,61 @@ fun VeranstaltungenUebersichtV2(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (events.isEmpty()) {
|
// Filter & Suche
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = { searchQuery = it },
|
||||||
|
placeholder = { Text("Suche nach Titel oder Verein...") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (searchQuery.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { searchQuery = "" }) {
|
||||||
|
Icon(Icons.Default.Clear, contentDescription = "Löschen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status Filter Chips
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.FilterList, contentDescription = null, tint = Color.Gray)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedStatus == null,
|
||||||
|
onClick = { selectedStatus = null },
|
||||||
|
label = { Text("Alle") }
|
||||||
|
)
|
||||||
|
availableStatuses.forEach { status ->
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedStatus == status,
|
||||||
|
onClick = { selectedStatus = if (selectedStatus == status) null else status },
|
||||||
|
label = { Text(status) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredEvents.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text("Keine Veranstaltungen gefunden.", color = Color.Gray)
|
Text(
|
||||||
|
if (searchQuery.isEmpty() && selectedStatus == null) "Keine Veranstaltungen gefunden."
|
||||||
|
else "Keine Ergebnisse für deine Suche/Filter.",
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
items(events.sortedByDescending { it.datumVon }) { event ->
|
items(filteredEvents) { event ->
|
||||||
val verein = vereine.find { it.id == event.veranstalterId }
|
val verein = vereine.find { it.id == event.veranstalterId }
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().clickable { onEventOpen(event.veranstalterId, event.id) },
|
modifier = Modifier.fillMaxWidth().clickable { onEventOpen(event.veranstalterId, event.id) },
|
||||||
@@ -60,12 +126,27 @@ fun VeranstaltungenUebersichtV2(
|
|||||||
"${verein?.name ?: "Unbekannter Verein"} | ${event.datumVon} bis ${event.datumBis ?: ""}",
|
"${verein?.name ?: "Unbekannter Verein"} | ${event.datumVon} bis ${event.datumBis ?: ""}",
|
||||||
style = MaterialTheme.typography.bodySmall
|
style = MaterialTheme.typography.bodySmall
|
||||||
)
|
)
|
||||||
|
if (event.beschreibung.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
event.beschreibung,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 2,
|
||||||
|
color = Color.DarkGray
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
event.status,
|
event.status,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -81,106 +162,496 @@ fun VeranstaltungenUebersichtV2(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VeranstaltungKonfigV2(
|
fun VeranstalterAnlegenWizard(
|
||||||
veranstalterId: Long,
|
onCancel: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onVereinCreated: (Long) -> Unit,
|
||||||
onSaved: (Long) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
DesktopThemeV2 {
|
var step by remember { mutableStateOf(1) } // 1: Suche in Stammdaten, 2: Details/Bestätigung
|
||||||
var currentStep by remember { mutableStateOf(1) }
|
|
||||||
var geraetName by remember { mutableStateOf("") }
|
|
||||||
var securityKey by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
// State für Suche
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
// State für Details (falls manuell oder ergänzt)
|
||||||
contentDescription = "Zurück",
|
var name by remember { mutableStateOf("") }
|
||||||
modifier = Modifier.clickable { onBack() })
|
var oeps by remember { mutableStateOf("") }
|
||||||
|
var ort by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.2f)),
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.3f))
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
if (currentStep == 1) "Neue Veranstaltung: Geräteschutz" else "Neue Veranstaltung: Basisdaten",
|
if (step == 1) "Schritt 1: Verein in Stammdaten finden" else "Schritt 2: Vereinsdaten bestätigen",
|
||||||
style = MaterialTheme.typography.titleLarge
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
IconButton(onClick = onCancel) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Abbrechen")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStep == 1) {
|
if (step == 1) {
|
||||||
// --- STEP 1: Device Onboarding ---
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
OutlinedTextField(
|
||||||
"Bevor du eine Veranstaltung anlegst, musst du dieses Gerät benennen und einen lokalen Sicherheitsschlüssel festlegen.",
|
value = searchQuery,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
onValueChange = { searchQuery = it },
|
||||||
color = Color.Gray
|
label = { Text("Nach Name, Ort oder OEPS-Nr suchen...") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
singleLine = true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val results = remember(searchQuery) {
|
||||||
|
if (searchQuery.length < 2) emptyList()
|
||||||
|
else StoreV2.oepsStammdaten.filter {
|
||||||
|
it.name.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.ort.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.oepsNummer.contains(searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.isNotEmpty()) {
|
||||||
|
LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) {
|
||||||
|
items(results) { v ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(v.name) },
|
||||||
|
supportingContent = { Text("${v.ort} | ${v.oepsNummer}") },
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
name = v.name
|
||||||
|
oeps = v.oepsNummer
|
||||||
|
ort = v.ort
|
||||||
|
step = 2
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (searchQuery.length >= 2) {
|
||||||
|
Text(
|
||||||
|
"Kein Verein gefunden? Du kannst die Daten auch manuell eingeben.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { step = 2 },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Manuell erfassen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = geraetName,
|
value = name,
|
||||||
onValueChange = { geraetName = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Gerätename (z.B. Meldestelle-1)") },
|
label = { Text("Vereinsname") },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = securityKey,
|
value = ort,
|
||||||
onValueChange = { securityKey = it },
|
onValueChange = { ort = it },
|
||||||
label = { Text("Lokaler Sicherheitsschlüssel") },
|
label = { Text("Ort") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = oeps,
|
||||||
|
onValueChange = { oeps = it },
|
||||||
|
label = { Text("OEPS-Nummer") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
visualTransformation = PasswordVisualTransformation()
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
)
|
) {
|
||||||
|
TextButton(onClick = { step = 1 }) { Text("Zurück zur Suche") }
|
||||||
val step1Enabled = geraetName.isNotBlank() && securityKey.length >= 8
|
Button(
|
||||||
Button(onClick = { currentStep = 2 }, enabled = step1Enabled) {
|
onClick = {
|
||||||
Text("Weiter zu den Veranstaltungsdaten")
|
val newId = StoreV2.addVerein(name, oeps, ort)
|
||||||
|
onVereinCreated(newId)
|
||||||
|
},
|
||||||
|
enabled = name.isNotBlank() && ort.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text("Verein anlegen & weiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (securityKey.isNotEmpty() && securityKey.length < 8) {
|
|
||||||
Text(
|
|
||||||
"Der Schlüssel muss mindestens 8 Zeichen lang sein.",
|
|
||||||
color = Color(0xFFB00020),
|
|
||||||
style = MaterialTheme.typography.labelSmall
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
// --- STEP 2: Event Data ---
|
@Composable
|
||||||
|
fun VeranstaltungKonfigV2(
|
||||||
|
veranstalterId: Long = 0,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSaved: (Long, Long) -> Unit, // eventId, veranstalterId
|
||||||
|
) {
|
||||||
|
DesktopThemeV2 {
|
||||||
|
var currentStep by remember { mutableStateOf(if (veranstalterId == 0L) 1 else 2) }
|
||||||
|
|
||||||
|
// Step 1: Veranstalterwahl
|
||||||
|
var selectedVereinId by remember { mutableStateOf(veranstalterId) }
|
||||||
|
var showVereinNeu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Step 2: Basisdaten
|
||||||
var titel by remember { mutableStateOf("") }
|
var titel by remember { mutableStateOf("") }
|
||||||
|
var untertitel by remember { mutableStateOf("") }
|
||||||
var von by remember { mutableStateOf("") }
|
var von by remember { mutableStateOf("") }
|
||||||
var bis by remember { mutableStateOf("") }
|
var bis by remember { mutableStateOf("") }
|
||||||
|
var ort by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
var showDatePickerVon by remember { mutableStateOf(false) }
|
||||||
|
var showDatePickerBis by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Step 3: Zusatzdaten
|
||||||
|
var logoUrl by remember { mutableStateOf("") }
|
||||||
|
var sponsorenText by remember { mutableStateOf("") } // Kommagetrennte Liste
|
||||||
|
|
||||||
|
val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE }
|
||||||
|
|
||||||
|
fun Long?.toLocalDate(): LocalDate? {
|
||||||
|
if (this == null) return null
|
||||||
|
return Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDatePickerVon) {
|
||||||
|
val datePickerState = rememberDatePickerState()
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showDatePickerVon = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
datePickerState.selectedDateMillis.toLocalDate()?.let {
|
||||||
|
von = it.format(dateFormatter)
|
||||||
|
}
|
||||||
|
showDatePickerVon = false
|
||||||
|
}) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDatePickerVon = false }) { Text("Abbrechen") }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
DatePicker(state = datePickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDatePickerBis) {
|
||||||
|
val datePickerState = rememberDatePickerState()
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showDatePickerBis = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
datePickerState.selectedDateMillis.toLocalDate()?.let {
|
||||||
|
bis = it.format(dateFormatter)
|
||||||
|
}
|
||||||
|
showDatePickerBis = false
|
||||||
|
}) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDatePickerBis = false }) { Text("Abbrechen") }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
DatePicker(state = datePickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
// Header & Navigation
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
currentStep--
|
||||||
|
} else {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
Text("Neue Veranstaltung anlegen", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text(
|
||||||
|
when (currentStep) {
|
||||||
|
1 -> "Schritt 1: Veranstalter auswählen"
|
||||||
|
2 -> "Schritt 2: Basisdaten der Veranstaltung"
|
||||||
|
3 -> "Schritt 3: Details & Sponsoren"
|
||||||
|
else -> ""
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { currentStep / 3f },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
when (currentStep) {
|
||||||
|
1 -> {
|
||||||
|
// --- SCHRITT 1: Veranstalterwahl ---
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
var search by remember { mutableStateOf("") }
|
||||||
|
val filteredVereine = remember(search) {
|
||||||
|
StoreV2.vereine.filter {
|
||||||
|
it.name.contains(search, ignoreCase = true) || it.ort.contains(search, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Für welchen Verein wird die Veranstaltung angelegt?", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = search,
|
||||||
|
onValueChange = { search = it },
|
||||||
|
label = { Text("Veranstalter suchen...") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(filteredVereine) { verein ->
|
||||||
|
val isSelected = selectedVereinId == verein.id
|
||||||
|
Surface(
|
||||||
|
onClick = { selectedVereinId = verein.id },
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||||
|
border = if (isSelected) null else androidx.compose.foundation.BorderStroke(
|
||||||
|
1.dp,
|
||||||
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(16.dp).fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(verein.name, fontWeight = FontWeight.Bold)
|
||||||
|
Text("${verein.ort} | ${verein.oepsNummer}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
if (isSelected) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
if (!showVereinNeu) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showVereinNeu = true },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Neuen Veranstalter / Verein anlegen")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VeranstalterAnlegenWizard(
|
||||||
|
onCancel = { showVereinNeu = false },
|
||||||
|
onVereinCreated = { newId ->
|
||||||
|
selectedVereinId = newId
|
||||||
|
showVereinNeu = false
|
||||||
|
currentStep = 2
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
2 -> {
|
||||||
|
// --- SCHRITT 2: Basisdaten ---
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Allgemeine Informationen", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = titel,
|
value = titel,
|
||||||
onValueChange = { titel = it },
|
onValueChange = { titel = it },
|
||||||
label = { Text("Titel (Pflicht)") },
|
label = { Text("Titel der Veranstaltung (z.B. Pfingstturnier 2026)") },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = untertitel,
|
||||||
|
onValueChange = { untertitel = it },
|
||||||
|
label = { Text("Untertitel / Slogan (optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
val dateVon = try {
|
||||||
|
LocalDate.parse(von, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val dateBis = try {
|
||||||
|
LocalDate.parse(bis, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val isDateRangeInvalid = dateVon != null && dateBis != null && dateBis.isBefore(dateVon)
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = von,
|
value = von,
|
||||||
onValueChange = { von = it },
|
onValueChange = { /* Schreibgeschützt, via Picker */ },
|
||||||
label = { Text("von (YYYY-MM-DD)") },
|
label = { Text("Datum von") },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f).clickable { showDatePickerVon = true },
|
||||||
|
enabled = false,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
disabledBorderColor = MaterialTheme.colorScheme.outline,
|
||||||
|
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showDatePickerVon = true }) {
|
||||||
|
Icon(Icons.Default.DateRange, contentDescription = "Datum wählen")
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = bis,
|
value = bis,
|
||||||
onValueChange = { bis = it },
|
onValueChange = { /* Schreibgeschützt, via Picker */ },
|
||||||
label = { Text("bis (YYYY-MM-DD)") },
|
label = { Text("Datum bis") },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f).clickable { showDatePickerBis = true },
|
||||||
|
enabled = false,
|
||||||
|
isError = isDateRangeInvalid,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
disabledBorderColor = if (isDateRangeInvalid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline,
|
||||||
|
disabledLabelColor = if (isDateRangeInvalid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showDatePickerBis = true }) {
|
||||||
|
Icon(Icons.Default.DateRange, contentDescription = "Datum wählen")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingText = {
|
||||||
|
if (isDateRangeInvalid) {
|
||||||
|
Text("Enddatum darf nicht vor dem Startdatum liegen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val validDates = von.isNotBlank() && (bis.isBlank() || bis >= von)
|
OutlinedTextField(
|
||||||
if (!validDates && von.isNotEmpty()) Text(
|
value = ort,
|
||||||
"bis-Datum darf nicht vor von-Datum liegen",
|
onValueChange = { ort = it },
|
||||||
color = Color(0xFFB00020)
|
label = { Text("Austragungsort (falls abweichend vom Vereinssitz)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
val enabled = titel.trim().isNotEmpty() && validDates
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
3 -> {
|
||||||
OutlinedButton(onClick = { currentStep = 1 }) { Text("Zurück") }
|
// --- SCHRITT 3: Details & Sponsoren ---
|
||||||
Button(onClick = {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
val id = System.currentTimeMillis()
|
Text("Branding & Partner", style = MaterialTheme.typography.titleMedium)
|
||||||
StoreV2.addEventFirst(
|
|
||||||
veranstalterId,
|
OutlinedTextField(
|
||||||
VeranstaltungV2(id, veranstalterId, titel.trim(), von.trim(), bis.trim().ifBlank { null })
|
value = logoUrl,
|
||||||
|
onValueChange = { logoUrl = it },
|
||||||
|
label = { Text("Logo-URL oder Pfad") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
supportingText = { Text("Optional: Link zu einem Turnierlogo") }
|
||||||
)
|
)
|
||||||
onSaved(id)
|
|
||||||
}, enabled = enabled) { Text("Veranstaltung anlegen") }
|
OutlinedTextField(
|
||||||
|
value = sponsorenText,
|
||||||
|
onValueChange = { sponsorenText = it },
|
||||||
|
label = { Text("Sponsoren (mit Komma trennen)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Vorschau Sponsoren:",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
sponsorenText.split(",").filter { it.isNotBlank() }.forEach { sponsor ->
|
||||||
|
SuggestionChip(onClick = {}, label = { Text(sponsor.trim()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer Navigation
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
OutlinedButton(onClick = { currentStep-- }) {
|
||||||
|
Text("Zurück")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.width(1.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (currentStep < 3) {
|
||||||
|
currentStep++
|
||||||
|
} else {
|
||||||
|
val id = System.currentTimeMillis()
|
||||||
|
val v = VeranstaltungV2(
|
||||||
|
id = id,
|
||||||
|
veranstalterId = selectedVereinId,
|
||||||
|
titel = titel.trim(),
|
||||||
|
datumVon = von.trim(),
|
||||||
|
datumBis = bis.trim().ifBlank { null },
|
||||||
|
untertitel = untertitel.trim(),
|
||||||
|
ort = ort.trim().ifBlank { StoreV2.vereine.find { it.id == selectedVereinId }?.ort ?: "" },
|
||||||
|
logoUrl = logoUrl.trim().ifBlank { null }
|
||||||
|
)
|
||||||
|
sponsorenText.split(",").filter { it.isNotBlank() }.forEach {
|
||||||
|
v.sponsoren.add(it.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreV2.addEventFirst(selectedVereinId, v)
|
||||||
|
onSaved(id, selectedVereinId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = when (currentStep) {
|
||||||
|
1 -> selectedVereinId != 0L
|
||||||
|
2 -> {
|
||||||
|
val dVon = try {
|
||||||
|
LocalDate.parse(von, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val dBis = try {
|
||||||
|
LocalDate.parse(bis, dateFormatter)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val rangeInvalid = dVon != null && dBis != null && dBis.isBefore(dVon)
|
||||||
|
titel.isNotBlank() && von.isNotBlank() && !rangeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
3 -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(if (currentStep == 3) "Veranstaltung final anlegen" else "Weiter")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ include(":frontend:features:veranstaltung-feature")
|
|||||||
include(":frontend:features:profile-feature")
|
include(":frontend:features:profile-feature")
|
||||||
include(":frontend:features:reiter-feature")
|
include(":frontend:features:reiter-feature")
|
||||||
include(":frontend:features:pferde-feature")
|
include(":frontend:features:pferde-feature")
|
||||||
|
include(":frontend:features:verein-feature")
|
||||||
include(":frontend:features:turnier-feature")
|
include(":frontend:features:turnier-feature")
|
||||||
include(":frontend:features:billing-feature")
|
include(":frontend:features:billing-feature")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user