feat(reiter-feature): introduce Reiter management module with screens, ViewModel, and domain models

- Added `reiter-feature` module for managing riders, including list and detail views.
- Implemented `MsMasterDetailLayout` for ReiterScreen, integrating `MsDataTable`, `MsFilterBar`, and `MsActionToolbar`.
- Defined domain models (`Reiter`, `LizenzKlasse`, `Sparte`, `ReiterStatus`) with mock data support.
- Updated roadmap to reflect progress in Phase 5: Routing & Screen-Komposition.
- Registered the new module in `settings.gradle.kts`.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-03-31 11:09:37 +02:00
parent 659e699c33
commit 94306329c9
6 changed files with 348 additions and 1 deletions

View File

@ -47,7 +47,7 @@ Eingabe von Stammdaten muss schnell und fehlerfrei erfolgen.
* [x] **`MsValidationWrapper`:** Konsistente Anzeige von Fehlern und Warnungen (z.B. ÖTO-Validierungsregeln).
* [x] **`MsSearchableSelect`:** Für die Verknüpfung von Reitern/Pferden (Autocomplete-Suche).
## Phase 4: Layout-Patterns & Navigation 🔵 [IN ARBEIT]
## Phase 4: Layout-Patterns & Navigation ✅ [ABGESCHLOSSEN]
Hier bringen wir alles zusammen, bevor das finale Routing implementiert wird.
@ -57,6 +57,16 @@ Hier bringen wir alles zusammen, bevor das finale Routing implementiert wird.
---
## Phase 5: Routing & Screen-Komposition 🔵 [IN ARBEIT]
In dieser Phase werden die Komponenten zu echten Features zusammengebaut.
* [x] **Reiter-Verwaltung (MVP):** Erster Screen mit `MsMasterDetailLayout`, `MsDataTable` und Editor.
* [ ] **Pferde-Verwaltung (MVP):** Analog zur Reiter-Verwaltung.
* [ ] **Navigation & Routing:** Integration der neuen Screens in die Hauptnavigation.
---
## Erfolgs-Metriken
* **Wiederverwendbarkeit:** > 80% der UI besteht aus `Ms`-Komponenten.

View File

@ -0,0 +1,30 @@
/**
* Feature-Modul: Reiter-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,46 @@
package at.mocode.frontend.features.reiter.domain
import androidx.compose.ui.graphics.Color
/**
* UI-Modell für einen Reiter.
*/
data class Reiter(
val id: String,
val vorname: String,
val nachname: String,
val satznummer: String?,
val lizenz: LizenzKlasse = LizenzKlasse.KEINE,
val sparte: Sparte = Sparte.KEINE,
val status: ReiterStatus = ReiterStatus.AKTIV
) {
val name: String get() = "$vorname $nachname"
}
enum class LizenzKlasse(val label: String) {
KEINE("-"),
R1("R1"),
R1D1("R1D1"),
R1S1("R1S1"),
R2("R2"),
R2D2("R2D2"),
R2S2("R2S2"),
R3("R3"),
R4("R4")
}
enum class Sparte(val label: String) {
KEINE("-"),
DRESSUR("Dressur"),
SPRINGEN("Springen"),
VIELSEITIGKEIT("Vielseitigkeit"),
VOLTIGIEREN("Voltigieren"),
FAHREN("Fahren"),
REINING("Reining")
}
enum class ReiterStatus(val label: String, val color: Color) {
AKTIV("Aktiv", Color(0xFF2E7D32)),
GESPERRT("Gesperrt", Color(0xFFC62828)),
INAKTIV("Inaktiv", Color(0xFF757575))
}

View File

@ -0,0 +1,169 @@
package at.mocode.frontend.features.reiter.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.reiter.domain.LizenzKlasse
import at.mocode.frontend.features.reiter.domain.Reiter
import at.mocode.frontend.features.reiter.domain.Sparte
@Composable
fun ReiterScreen(
viewModel: ReiterViewModel
) {
val uiState = viewModel.uiState
MsMasterDetailLayout(
master = {
ReiterListContent(
uiState = uiState,
onSearchChange = viewModel::onSearchQueryChange,
onReiterSelected = viewModel::selectReiter
)
},
detail = {
if (uiState.isEditing) {
ReiterEditorContent(
uiState = uiState,
onVornameChange = viewModel::onEditVornameChange,
onNachnameChange = viewModel::onEditNameChange,
onLizenzChange = viewModel::onEditLizenzChange,
onSparteChange = viewModel::onEditSparteChange,
onSave = viewModel::onSave,
onCancel = viewModel::onCancel
)
} else {
PlaceholderContent(
title = "Kein Reiter ausgewählt",
subtitle = "Wählen Sie einen Reiter aus der Liste aus oder legen Sie einen neuen an."
)
}
}
)
}
@Composable
private fun ReiterListContent(
uiState: ReiterUiState,
onSearchChange: (String) -> Unit,
onReiterSelected: (Reiter) -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
MsFilterBar(
searchQuery = uiState.searchQuery,
onSearchQueryChange = onSearchChange,
resultCount = uiState.searchResults.size
)
Spacer(Modifier.height(8.dp))
MsDataTable(
items = uiState.searchResults,
columns = listOf(
MsColumnDefinition(
title = "Vorname",
weight = 1f,
cellRenderer = { Text(it.vorname, style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Nachname",
weight = 1f,
cellRenderer = { Text(it.nachname, style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Lizenz",
width = 80.dp,
cellRenderer = { Text(it.lizenz.label, 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 = onReiterSelected
)
}
}
@Composable
private fun ReiterEditorContent(
uiState: ReiterUiState,
onVornameChange: (String) -> Unit,
onNachnameChange: (String) -> Unit,
onLizenzChange: (LizenzKlasse) -> Unit,
onSparteChange: (Sparte) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
MsActionToolbar(
title = "Reiter Details",
onSave = onSave,
onCancel = onCancel
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editVorname,
onValueChange = onVornameChange,
label = "Vorname",
modifier = Modifier.weight(1f)
)
MsTextField(
value = uiState.editName,
onValueChange = onNachnameChange,
label = "Nachname",
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsEnumDropdown(
label = "Lizenzklasse",
options = LizenzKlasse.values(),
selectedOption = uiState.editLizenz,
onOptionSelected = onLizenzChange,
optionLabel = { it.label },
modifier = Modifier.weight(1f)
)
MsEnumDropdown(
label = "Hauptsparte",
options = Sparte.values(),
selectedOption = uiState.editSparte,
onOptionSelected = onSparteChange,
optionLabel = { it.label },
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(24.dp))
// Beispiel für ValidationWrapper
MsValidationWrapper(
messages = listOf(
ValidationMessage("Warnung: Lizenz läuft in 14 Tagen ab.", ValidationSeverity.WARNING)
)
) {
Text(
"Zusätzliche Reiter-Informationen",
style = MaterialTheme.typography.titleSmall
)
}
}
}

View File

@ -0,0 +1,91 @@
package at.mocode.frontend.features.reiter.presentation
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import at.mocode.frontend.features.reiter.domain.LizenzKlasse
import at.mocode.frontend.features.reiter.domain.Reiter
import at.mocode.frontend.features.reiter.domain.ReiterStatus
import at.mocode.frontend.features.reiter.domain.Sparte
/**
* UI-State für die Reiter-Verwaltung.
*/
data class ReiterUiState(
val searchResults: List<Reiter> = emptyList(),
val searchQuery: String = "",
val selectedReiter: Reiter? = null,
val isEditing: Boolean = false,
val isLoading: Boolean = false,
val editName: String = "",
val editVorname: String = "",
val editLizenz: LizenzKlasse = LizenzKlasse.KEINE,
val editSparte: Sparte = Sparte.KEINE,
val editStatus: ReiterStatus = ReiterStatus.AKTIV
)
/**
* ViewModel für die Reiter-Verwaltung.
* In einem echten Szenario würden wir hier ein Repository injizieren.
*/
class ReiterViewModel {
var uiState by mutableStateOf(ReiterUiState())
private set
init {
// Initialer Load (Mock-Daten)
loadReiter()
}
private fun loadReiter() {
val mockData = listOf(
Reiter("1", "Stefan", "Möbius", "123456", LizenzKlasse.R2D2, Sparte.DRESSUR, ReiterStatus.AKTIV),
Reiter("2", "Julia", "Reiterin", "654321", LizenzKlasse.R1, Sparte.SPRINGEN, ReiterStatus.AKTIV),
Reiter("3", "Max", "Mustermann", "112233", LizenzKlasse.KEINE, Sparte.KEINE, ReiterStatus.GESPERRT),
Reiter("4", "Lisa", "Springen", "445566", LizenzKlasse.R3, Sparte.SPRINGEN, ReiterStatus.AKTIV)
)
uiState = uiState.copy(searchResults = mockData)
}
fun onSearchQueryChange(query: String) {
uiState = uiState.copy(searchQuery = query)
// Hier würde die Filter-Logik greifen
}
fun selectReiter(reiter: Reiter) {
uiState = uiState.copy(
selectedReiter = reiter,
isEditing = true,
editVorname = reiter.vorname,
editName = reiter.nachname,
editLizenz = reiter.lizenz,
editSparte = reiter.sparte,
editStatus = reiter.status
)
}
fun onEditVornameChange(value: String) {
uiState = uiState.copy(editVorname = value)
}
fun onEditNameChange(value: String) {
uiState = uiState.copy(editName = value)
}
fun onEditLizenzChange(value: LizenzKlasse) {
uiState = uiState.copy(editLizenz = value)
}
fun onEditSparteChange(value: Sparte) {
uiState = uiState.copy(editSparte = value)
}
fun onSave() {
// Mock-Speichern
uiState = uiState.copy(isEditing = false)
}
fun onCancel() {
uiState = uiState.copy(isEditing = false)
}
}

View File

@ -126,6 +126,7 @@ include(":frontend:features:zns-import-feature")
include(":frontend:features:veranstalter-feature")
include(":frontend:features:veranstaltung-feature")
include(":frontend:features:profile-feature")
include(":frontend:features:reiter-feature")
include(":frontend:features:turnier-feature")
include(":frontend:features:billing-feature")