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:
parent
1699c24875
commit
496e801943
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
32
frontend/features/verein-feature/build.gradle.kts
Normal file
32
frontend/features/verein-feature/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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:pferde-feature"))
|
||||
implementation(project(":frontend:features:billing-feature"))
|
||||
implementation(project(":frontend:features:verein-feature"))
|
||||
|
||||
// Compose Desktop
|
||||
implementation(compose.desktop.currentOs)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user