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:
Stefan Mogeritsch 2026-03-31 15:00:19 +02:00
parent 1699c24875
commit 496e801943
15 changed files with 1109 additions and 151 deletions

View File

@ -1,9 +1,18 @@
---
type: Journal
status: ACTIVE
owner: Curator
last_update: 2026-03-31
---
## Nachtrag 31.03.2026 15:45
- **Fehlerbehebung Desktop-Shell Build:**
- **`VereinViewModel.kt`:** Das ViewModel erbt nun korrekt von `androidx.lifecycle.ViewModel`. Dies behebt einen "
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)
@ -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
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
@ -19,18 +28,25 @@ er lokal arbeitet.
- **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
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)
- **Neuer Screen `VeranstaltungenUebersichtV2`:** Implementierung einer zentralen Übersicht, die alle im lokalen Store
vorhandenen Veranstaltungen über alle Veranstalter hinweg anzeigt.
- **Funktionalität:**
- Listendarstellung mit Titel, Verein, Datum und Status.
- Navigation zum "Cockpit" einer Veranstaltung (`VeranstaltungUebersicht`).
- Button zur Neuanlage einer Veranstaltung (leitet zur Veranstalter-Auswahl weiter).
- **KPI-Kacheln:** Erweiterung um eine Kachel "VEREINE", die als Schnelleinstieg zur Vereinsverwaltung dient.
### 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
`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
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
- `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/v2/VeranstaltungScreens.kt` (Neu:
`VeranstaltungenUebersichtV2`)
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt` (Neu: `allEvents()`, `seed()`)
`VeranstaltungenUebersichtV2`, `VeranstalterAnlegenWizard`)
- `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()`)
## Nächste Schritte
@ -55,3 +73,51 @@ er lokal arbeitet.
- [ ] Verifikation der Detail-Ansicht für Turniere.
- [ ] Implementierung der mDNS Discovery für die lokale Vernetzung.
- [ ] 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.

View File

@ -24,8 +24,10 @@ sealed class AppScreen(val route: String) {
data object VeranstalterAuswahl : AppScreen("/veranstalter/auswahl")
data object VeranstalterNeu : AppScreen("/veranstalter/neu")
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) :
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 object Reiter : AppScreen("/reiter")
data object Pferde : AppScreen("/pferde")
data object Vereine : AppScreen("/vereine")
data object Funktionaere : AppScreen("/funktionaere")
data object Meisterschaften : AppScreen("/meisterschaften")
data object Cups : AppScreen("/cups")
@ -68,6 +71,7 @@ sealed class AppScreen(val route: String) {
"/veranstaltung/neu" -> VeranstaltungNeu
"/reiter" -> Reiter
"/pferde" -> Pferde
"/vereine" -> Vereine
"/funktionaere" -> Funktionaere
"/meisterschaften" -> Meisterschaften
"/cups" -> Cups

View File

@ -1,6 +1,7 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -39,6 +40,7 @@ fun AdminUebersichtScreen(
onVeranstalterAuswahl: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit,
onPingService: () -> Unit = {},
onVereineOeffnen: () -> Unit = {},
) {
// Placeholder-Daten für die UI-Struktur (sichtbar als Cards)
val sample = listOf(
@ -66,6 +68,7 @@ fun AdminUebersichtScreen(
inVorbereitung = 0,
gesamt = 0,
archiv = 0,
onVereineClick = onVereineOeffnen
)
// Toolbar
@ -155,6 +158,7 @@ private fun KpiKachelRow(
inVorbereitung: Int,
gesamt: Int,
archiv: Int,
onVereineClick: () -> Unit = {},
) {
Row(
modifier = Modifier
@ -175,10 +179,10 @@ private fun KpiKachelRow(
modifier = Modifier.weight(1f),
)
KpiKachel(
label = "GESAMT",
wert = gesamt.toString(),
label = "VEREINE",
wert = "4", // Mock
akzentFarbe = Color(0xFF6B7280),
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f).clickable { onVereineClick() },
)
KpiKachel(
label = "ARCHIV",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,6 +38,7 @@ kotlin {
implementation(project(":frontend:features:reiter-feature"))
implementation(project(":frontend:features:pferde-feature"))
implementation(project(":frontend:features:billing-feature"))
implementation(project(":frontend:features:verein-feature"))
// Compose Desktop
implementation(compose.desktop.currentOs)

View File

@ -42,7 +42,7 @@ fun DesktopApp() {
&& 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.TurnierNeu && currentScreen !is AppScreen.Vereine
) {
LaunchedEffect(Unit) {
// Standard: Direkt zur Veranstaltungs-Übersicht (Offline-First-Modus)

View File

@ -13,6 +13,7 @@ import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.frontend.features.billing.di.billingModule
import at.mocode.frontend.features.profile.di.profileModule
import at.mocode.frontend.features.verein.di.vereinFeatureModule
import at.mocode.nennung.feature.di.nennungFeatureModule
import at.mocode.ping.feature.di.pingFeatureModule
import at.mocode.zns.feature.di.znsImportModule
@ -35,6 +36,7 @@ fun main() = application {
znsImportModule,
profileModule,
billingModule,
vereinFeatureModule,
desktopModule,
)
}

View File

@ -5,10 +5,8 @@ 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.automirrored.filled.Logout
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.features.profile.presentation.ProfileScreen
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.PingViewModel
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.VeranstaltungNeuScreen
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
// Primärfarbe der TopBar (kann später ins Theme ausgelagert werden)
private val TopBarColor = Color(0xFF1E3A8A)
@ -107,7 +108,7 @@ private fun DesktopTopBar(
// Root-Link
Text(
text = "🏠 Admin - Verwaltung",
text = "Veranstaltungen",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
@ -242,18 +243,21 @@ private fun DesktopTopBar(
fontSize = 14.sp,
)
}
is AppScreen.Vereine -> {
BreadcrumbSeparator()
Text(
text = "Vereine",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
else -> {}
}
}
// Logout rechts
IconButton(onClick = onLogout) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Abmelden",
tint = TopBarTextColor,
)
}
// Logout wurde auf Kundenwunsch entfernt
}
}
@ -302,7 +306,7 @@ private fun DesktopContentArea(
is AppScreen.Veranstaltungen -> {
at.mocode.desktop.v2.VeranstaltungenUebersichtV2(
onEventOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, eId)) },
onNewEvent = { onNavigate(AppScreen.VeranstalterAuswahl) }
onNewEvent = { onNavigate(AppScreen.VeranstaltungKonfig()) }
)
}
@ -335,19 +339,15 @@ private fun DesktopContentArea(
}
is AppScreen.VeranstaltungKonfig -> {
val vId = currentScreen.veranstalterId
// V2: Validierung über StoreV2
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(
veranstalterId = vId,
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) },
onSaved = { evtId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) }
)
}
// 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)) }
)
}
is AppScreen.VeranstaltungUebersicht -> {
val vId = currentScreen.veranstalterId
@ -438,6 +438,14 @@ private fun DesktopContentArea(
)
}
// Vereins-Verwaltung
is AppScreen.Vereine -> {
val vereinViewModel: VereinViewModel = koinViewModel()
VereinScreen(
viewModel = vereinViewModel
)
}
// Fallback → Root
else -> AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },

View File

@ -12,19 +12,39 @@ data class Verein(
data class VeranstaltungV2(
val id: Long,
val veranstalterId: Long,
var veranstalterId: Long,
var titel: String,
var datumVon: String,
var datumBis: String?,
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 {
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(
Verein(1, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."),
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()
fun seed() {
@ -40,7 +60,8 @@ object StoreV2 {
titel = "Frühjahrsturnier Neumarkt/M. 2026",
datumVon = "2026-04-10",
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",
datumVon = "2026-05-20",
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"))

View File

@ -7,27 +7,45 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
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
fun VeranstaltungenUebersichtV2(
onEventOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
onNewEvent: () -> Unit
) {
DesktopThemeV2 {
val events = remember { StoreV2.allEvents() }
val allEvents = 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 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)) {
// Header
Row(
Modifier.fillMaxWidth(),
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) {
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 {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(events.sortedByDescending { it.datumVon }) { event ->
items(filteredEvents) { event ->
val verein = vereine.find { it.id == event.veranstalterId }
Card(
modifier = Modifier.fillMaxWidth().clickable { onEventOpen(event.veranstalterId, event.id) },
@ -60,12 +126,27 @@ fun VeranstaltungenUebersichtV2(
"${verein?.name ?: "Unbekannter Verein"} | ${event.datumVon} bis ${event.datumBis ?: ""}",
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(
event.status,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Text(
event.status,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
@ -81,106 +162,496 @@ fun VeranstaltungenUebersichtV2(
}
@Composable
fun VeranstaltungKonfigV2(
veranstalterId: Long,
onBack: () -> Unit,
onSaved: (Long) -> Unit,
fun VeranstalterAnlegenWizard(
onCancel: () -> Unit,
onVereinCreated: (Long) -> Unit,
) {
DesktopThemeV2 {
var currentStep by remember { mutableStateOf(1) }
var geraetName by remember { mutableStateOf("") }
var securityKey by remember { mutableStateOf("") }
var step by remember { mutableStateOf(1) } // 1: Suche in Stammdaten, 2: Details/Bestätigung
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
modifier = Modifier.clickable { onBack() })
// State für Suche
var searchQuery by remember { mutableStateOf("") }
// State für Details (falls manuell oder ergänzt)
var name by remember { mutableStateOf("") }
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(
if (currentStep == 1) "Neue Veranstaltung: Geräteschutz" else "Neue Veranstaltung: Basisdaten",
style = MaterialTheme.typography.titleLarge
if (step == 1) "Schritt 1: Verein in Stammdaten finden" else "Schritt 2: Vereinsdaten bestätigen",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onCancel) {
Icon(Icons.Default.Close, contentDescription = "Abbrechen")
}
}
if (currentStep == 1) {
// --- STEP 1: Device Onboarding ---
Text(
"Bevor du eine Veranstaltung anlegst, musst du dieses Gerät benennen und einen lokalen Sicherheitsschlüssel festlegen.",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
OutlinedTextField(
value = geraetName,
onValueChange = { geraetName = it },
label = { Text("Gerätename (z.B. Meldestelle-1)") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = securityKey,
onValueChange = { securityKey = it },
label = { Text("Lokaler Sicherheitsschlüssel") },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation()
)
val step1Enabled = geraetName.isNotBlank() && securityKey.length >= 8
Button(onClick = { currentStep = 2 }, enabled = step1Enabled) {
Text("Weiter zu den Veranstaltungsdaten")
}
if (securityKey.isNotEmpty() && securityKey.length < 8) {
Text(
"Der Schlüssel muss mindestens 8 Zeichen lang sein.",
color = Color(0xFFB00020),
style = MaterialTheme.typography.labelSmall
)
}
} else {
// --- STEP 2: Event Data ---
var titel by remember { mutableStateOf("") }
var von by remember { mutableStateOf("") }
var bis by remember { mutableStateOf("") }
OutlinedTextField(
value = titel,
onValueChange = { titel = it },
label = { Text("Titel (Pflicht)") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (step == 1) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = von,
onValueChange = { von = it },
label = { Text("von (YYYY-MM-DD)") },
modifier = Modifier.weight(1f)
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text("Nach Name, Ort oder OEPS-Nr suchen...") },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true
)
OutlinedTextField(
value = bis,
onValueChange = { bis = it },
label = { Text("bis (YYYY-MM-DD)") },
modifier = Modifier.weight(1f)
)
}
val validDates = von.isNotBlank() && (bis.isBlank() || bis >= von)
if (!validDates && von.isNotEmpty()) Text(
"bis-Datum darf nicht vor von-Datum liegen",
color = Color(0xFFB00020)
)
val enabled = titel.trim().isNotEmpty() && validDates
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)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { currentStep = 1 }) { Text("Zurück") }
Button(onClick = {
val id = System.currentTimeMillis()
StoreV2.addEventFirst(
veranstalterId,
VeranstaltungV2(id, veranstalterId, titel.trim(), von.trim(), bis.trim().ifBlank { null })
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
)
onSaved(id)
}, enabled = enabled) { Text("Veranstaltung anlegen") }
OutlinedButton(
onClick = { step = 2 },
modifier = Modifier.fillMaxWidth()
) {
Text("Manuell erfassen")
}
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Vereinsname") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
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(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
TextButton(onClick = { step = 1 }) { Text("Zurück zur Suche") }
Button(
onClick = {
val newId = StoreV2.addVerein(name, oeps, ort)
onVereinCreated(newId)
},
enabled = name.isNotBlank() && ort.isNotBlank()
) {
Text("Verein anlegen & weiter")
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@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 untertitel by remember { mutableStateOf("") }
var von 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(
value = titel,
onValueChange = { titel = it },
label = { Text("Titel der Veranstaltung (z.B. Pfingstturnier 2026)") },
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)) {
OutlinedTextField(
value = von,
onValueChange = { /* Schreibgeschützt, via Picker */ },
label = { Text("Datum von") },
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(
value = bis,
onValueChange = { /* Schreibgeschützt, via Picker */ },
label = { Text("Datum bis") },
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.")
}
}
)
}
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Austragungsort (falls abweichend vom Vereinssitz)") },
modifier = Modifier.fillMaxWidth()
)
}
}
3 -> {
// --- SCHRITT 3: Details & Sponsoren ---
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Branding & Partner", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = logoUrl,
onValueChange = { logoUrl = it },
label = { Text("Logo-URL oder Pfad") },
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Optional: Link zu einem Turnierlogo") }
)
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")
}
}
}

View File

@ -128,6 +128,7 @@ include(":frontend:features:veranstaltung-feature")
include(":frontend:features:profile-feature")
include(":frontend:features:reiter-feature")
include(":frontend:features:pferde-feature")
include(":frontend:features:verein-feature")
include(":frontend:features:turnier-feature")
include(":frontend:features:billing-feature")