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