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

- Added `pferde-feature` module for managing horses, including list, detail, and editing views.
- Implemented `MsMasterDetailLayout` for PferdeScreen, integrating `MsDataTable`, `MsFilterBar`, and `MsActionToolbar`.
- Defined domain models (`Pferd`, `Geschlecht`, `PferdeStatus`) with mock data support.
- Updated roadmap to mark `Pferde-Verwaltung (MVP)` as complete.
- Registered the new module in `settings.gradle.kts` and `meldestelle-desktop` build configuration.
- Added previews for Pferde and Reiter components to support IDE render.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-03-31 12:19:54 +02:00
parent 94306329c9
commit 6bbf6dc966
13 changed files with 481 additions and 18 deletions

View File

@ -62,7 +62,7 @@ Hier bringen wir alles zusammen, bevor das finale Routing implementiert wird.
In dieser Phase werden die Komponenten zu echten Features zusammengebaut. In dieser Phase werden die Komponenten zu echten Features zusammengebaut.
* [x] **Reiter-Verwaltung (MVP):** Erster Screen mit `MsMasterDetailLayout`, `MsDataTable` und Editor. * [x] **Reiter-Verwaltung (MVP):** Erster Screen mit `MsMasterDetailLayout`, `MsDataTable` und Editor.
* [ ] **Pferde-Verwaltung (MVP):** Analog zur Reiter-Verwaltung. * [x] **Pferde-Verwaltung (MVP):** Analog zur Reiter-Verwaltung (Fertiggestellt).
* [ ] **Navigation & Routing:** Integration der neuen Screens in die Hauptnavigation. * [ ] **Navigation & Routing:** Integration der neuen Screens in die Hauptnavigation.
--- ---

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

View File

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -40,3 +41,17 @@ fun MsCard(
} }
} }
} }
// Preview für IDE (muss in jvmMain liegen um in IDEA gerendert zu werden,
// oder hier bleiben als Dokumentation)
@Composable
fun MsCardPreviewContent() {
MaterialTheme {
Column(modifier = Modifier.padding(16.dp)) {
MsCard {
Text("Dies ist eine MsCard", style = MaterialTheme.typography.bodyMedium)
Text("Mit High-Density Content.", style = MaterialTheme.typography.bodySmall)
}
}
}
}

View File

@ -0,0 +1,32 @@
/**
* Feature-Modul: Pferde-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,29 @@
package at.mocode.frontend.features.pferde.domain
import androidx.compose.ui.graphics.Color
/**
* UI-Modell für ein Pferd.
*/
data class Pferd(
val id: String,
val name: String,
val lebensnummer: String,
val geschlecht: Geschlecht = Geschlecht.WALLACH,
val farbe: String = "",
val geburtsjahr: Int? = null,
val status: PferdeStatus = PferdeStatus.AKTIV
)
enum class Geschlecht(val label: String) {
WALLACH("Wallach"),
STUTE("Stute"),
HENGST("Hengst")
}
enum class PferdeStatus(val label: String, val color: Color) {
AKTIV("Aktiv", Color(0xFF2E7D32)),
INAKTIV("Inaktiv", Color(0xFF757575)),
GESTOKEN("Gestorben", Color(0xFFC62828)),
VERKAUFT("Verkauft", Color(0xFF0277BD))
}

View File

@ -0,0 +1,204 @@
package at.mocode.frontend.features.pferde.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.pferde.domain.Geschlecht
import at.mocode.frontend.features.pferde.domain.Pferd
import at.mocode.frontend.features.pferde.domain.PferdeStatus
@Composable
fun PferdeScreen(
viewModel: PferdeViewModel = PferdeViewModel()
) {
val uiState = viewModel.uiState
MsMasterDetailLayout(
master = {
PferdeListContent(
uiState = uiState,
onSearchChange = viewModel::onSearchQueryChange,
onPferdSelected = viewModel::selectPferd
)
},
detail = {
if (uiState.isEditing) {
PferdeEditorContent(
uiState = uiState,
onNameChange = viewModel::onEditNameChange,
onLebensnummerChange = viewModel::onEditLebensnummerChange,
onGeschlechtChange = viewModel::onEditGeschlechtChange,
onFarbeChange = viewModel::onEditFarbeChange,
onGeburtsjahrChange = viewModel::onEditGeburtsjahrChange,
onStatusChange = viewModel::onEditStatusChange,
onSave = viewModel::onSave,
onCancel = viewModel::onCancel
)
} else {
PlaceholderContent(
title = "Kein Pferd ausgewählt",
subtitle = "Wählen Sie ein Pferd aus der Liste aus oder legen Sie ein neues an."
)
}
}
)
}
@Composable
private fun PferdeListContent(
uiState: PferdeUiState,
onSearchChange: (String) -> Unit,
onPferdSelected: (Pferd) -> 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 = "Name",
weight = 1f,
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Lebensnummer",
width = 150.dp,
cellRenderer = { Text(it.lebensnummer, 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 = onPferdSelected
)
}
}
@Composable
private fun PferdeEditorContent(
uiState: PferdeUiState,
onNameChange: (String) -> Unit,
onLebensnummerChange: (String) -> Unit,
onGeschlechtChange: (Geschlecht) -> Unit,
onFarbeChange: (String) -> Unit,
onGeburtsjahrChange: (String) -> Unit,
onStatusChange: (PferdeStatus) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
MsActionToolbar(
title = "Pferde Details",
onSave = onSave,
onCancel = onCancel
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editName,
onValueChange = onNameChange,
label = "Name",
modifier = Modifier.weight(1f)
)
MsTextField(
value = uiState.editLebensnummer,
onValueChange = onLebensnummerChange,
label = "Lebensnummer",
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsEnumDropdown(
label = "Geschlecht",
options = Geschlecht.entries.toTypedArray(),
selectedOption = uiState.editGeschlecht,
onOptionSelected = onGeschlechtChange,
optionLabel = { it.label },
modifier = Modifier.weight(1f)
)
MsTextField(
value = uiState.editFarbe,
onValueChange = onFarbeChange,
label = "Farbe",
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editGeburtsjahr,
onValueChange = onGeburtsjahrChange,
label = "Geburtsjahr",
modifier = Modifier.weight(1f)
)
MsEnumDropdown(
label = "Status",
options = PferdeStatus.entries.toTypedArray(),
selectedOption = uiState.editStatus,
onOptionSelected = onStatusChange,
optionLabel = { it.label },
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(24.dp))
if (uiState.editStatus == PferdeStatus.INAKTIV) {
MsValidationWrapper(
messages = listOf(
ValidationMessage(
"Pferd ist als inaktiv markiert und kann nicht für Nennungen verwendet werden.",
ValidationSeverity.WARNING
)
)
) {
Text(
"Zusätzliche Pferde-Informationen",
style = MaterialTheme.typography.titleSmall
)
}
}
}
}
/**
* In-Place Preview für den PferdeScreen.
*/
@Composable
fun PferdeScreenPreviewContent() {
val viewModel = PferdeViewModel()
at.mocode.frontend.core.designsystem.theme.AppTheme {
Surface {
PferdeScreen(viewModel = viewModel)
}
}
}

View File

@ -0,0 +1,98 @@
package at.mocode.frontend.features.pferde.presentation
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import at.mocode.frontend.features.pferde.domain.Geschlecht
import at.mocode.frontend.features.pferde.domain.Pferd
import at.mocode.frontend.features.pferde.domain.PferdeStatus
/**
* UI-State für die Pferde-Verwaltung.
*/
data class PferdeUiState(
val searchResults: List<Pferd> = emptyList(),
val searchQuery: String = "",
val selectedPferd: Pferd? = null,
val isEditing: Boolean = false,
val isLoading: Boolean = false,
val editName: String = "",
val editLebensnummer: String = "",
val editGeschlecht: Geschlecht = Geschlecht.WALLACH,
val editFarbe: String = "",
val editGeburtsjahr: String = "",
val editStatus: PferdeStatus = PferdeStatus.AKTIV
)
/**
* ViewModel für die Pferde-Verwaltung.
*/
open class PferdeViewModel(initialLoad: Boolean = true) {
var uiState by mutableStateOf(PferdeUiState())
protected set
init {
if (initialLoad) {
loadPferde()
}
}
private fun loadPferde() {
val mockData = listOf(
Pferd("1", "Bella", "040001234567801", Geschlecht.STUTE, "Braun", 2015, PferdeStatus.AKTIV),
Pferd("2", "Casanova", "040001234567802", Geschlecht.WALLACH, "Schimmel", 2012, PferdeStatus.AKTIV),
Pferd("3", "Spirit", "040001234567803", Geschlecht.HENGST, "Rappe", 2018, PferdeStatus.AKTIV),
Pferd("4", "Lucky", "040001234567804", Geschlecht.WALLACH, "Fuchs", 2010, PferdeStatus.VERKAUFT)
)
uiState = uiState.copy(searchResults = mockData)
}
fun onSearchQueryChange(query: String) {
uiState = uiState.copy(searchQuery = query)
}
fun selectPferd(pferd: Pferd) {
uiState = uiState.copy(
selectedPferd = pferd,
isEditing = true,
editName = pferd.name,
editLebensnummer = pferd.lebensnummer,
editGeschlecht = pferd.geschlecht,
editFarbe = pferd.farbe,
editGeburtsjahr = pferd.geburtsjahr?.toString() ?: "",
editStatus = pferd.status
)
}
fun onEditNameChange(value: String) {
uiState = uiState.copy(editName = value)
}
fun onEditLebensnummerChange(value: String) {
uiState = uiState.copy(editLebensnummer = value)
}
fun onEditGeschlechtChange(value: Geschlecht) {
uiState = uiState.copy(editGeschlecht = value)
}
fun onEditFarbeChange(value: String) {
uiState = uiState.copy(editFarbe = value)
}
fun onEditGeburtsjahrChange(value: String) {
uiState = uiState.copy(editGeburtsjahr = value)
}
fun onEditStatusChange(value: PferdeStatus) {
uiState = uiState.copy(editStatus = value)
}
fun onSave() {
uiState = uiState.copy(isEditing = false)
}
fun onCancel() {
uiState = uiState.copy(isEditing = false)
}
}

View File

@ -136,7 +136,7 @@ private fun ReiterEditorContent(
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsEnumDropdown( MsEnumDropdown(
label = "Lizenzklasse", label = "Lizenzklasse",
options = LizenzKlasse.values(), options = LizenzKlasse.entries.toTypedArray(),
selectedOption = uiState.editLizenz, selectedOption = uiState.editLizenz,
onOptionSelected = onLizenzChange, onOptionSelected = onLizenzChange,
optionLabel = { it.label }, optionLabel = { it.label },
@ -144,7 +144,7 @@ private fun ReiterEditorContent(
) )
MsEnumDropdown( MsEnumDropdown(
label = "Hauptsparte", label = "Hauptsparte",
options = Sparte.values(), options = Sparte.entries.toTypedArray(),
selectedOption = uiState.editSparte, selectedOption = uiState.editSparte,
onOptionSelected = onSparteChange, onOptionSelected = onSparteChange,
optionLabel = { it.label }, optionLabel = { it.label },
@ -167,3 +167,14 @@ private fun ReiterEditorContent(
} }
} }
} }
@Composable
fun ReiterScreenPreviewContent() {
val viewModel = ReiterViewModel().apply {
// Optional: Hier könnten Mock-Daten direkt gesetzt werden,
// falls das ViewModel dies unterstützt.
}
MaterialTheme {
ReiterScreen(viewModel = viewModel)
}
}

View File

@ -28,13 +28,15 @@ data class ReiterUiState(
* ViewModel für die Reiter-Verwaltung. * ViewModel für die Reiter-Verwaltung.
* In einem echten Szenario würden wir hier ein Repository injizieren. * In einem echten Szenario würden wir hier ein Repository injizieren.
*/ */
class ReiterViewModel { open class ReiterViewModel(initialLoad: Boolean = true) {
var uiState by mutableStateOf(ReiterUiState()) var uiState by mutableStateOf(ReiterUiState())
private set protected set
init { init {
// Initialer Load (Mock-Daten) if (initialLoad) {
loadReiter() // Initialer Load (Mock-Daten)
loadReiter()
}
} }
private fun loadReiter() { private fun loadReiter() {

View File

@ -0,0 +1,66 @@
package at.mocode.frontend.features.reiter.presentation
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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
import at.mocode.wui.preview.ComponentPreview
/**
* Hilf's-ViewModel für die Vorschau, um den Status direkt setzen zu können.
*/
private class PreviewReiterViewModel(initialState: ReiterUiState) : ReiterViewModel(initialLoad = false) {
init {
uiState = initialState
}
}
@ComponentPreview
@Composable
fun PreviewReiterScreen_List() {
val viewModel = ReiterViewModel() // Nutzt die Mock-Daten aus dem init-Block
MaterialTheme {
ReiterScreen(viewModel = viewModel)
}
}
@ComponentPreview
@Composable
fun PreviewReiterScreen_Editing() {
val mockReiter = Reiter(
id = "1",
vorname = "Stefan",
nachname = "Möbius",
satznummer = "123456",
lizenz = LizenzKlasse.R2D2,
sparte = Sparte.DRESSUR,
status = ReiterStatus.AKTIV
)
val viewModel = PreviewReiterViewModel(
ReiterUiState(
searchResults = listOf(mockReiter),
selectedReiter = mockReiter,
isEditing = true,
editVorname = mockReiter.vorname,
editName = mockReiter.nachname,
editLizenz = mockReiter.lizenz,
editSparte = mockReiter.sparte,
editStatus = mockReiter.status
)
)
MaterialTheme {
ReiterScreen(viewModel = viewModel)
}
}
@ComponentPreview
@Composable
fun PreviewReiterScreen_Empty() {
val viewModel = PreviewReiterViewModel(ReiterUiState())
MaterialTheme {
ReiterScreen(viewModel = viewModel)
}
}

View File

@ -35,6 +35,8 @@ kotlin {
implementation(projects.frontend.features.veranstaltungFeature) implementation(projects.frontend.features.veranstaltungFeature)
implementation(projects.frontend.features.turnierFeature) implementation(projects.frontend.features.turnierFeature)
implementation(project(":frontend:features:profile-feature")) implementation(project(":frontend:features:profile-feature"))
implementation(project(":frontend:features:reiter-feature"))
implementation(project(":frontend:features:pferde-feature"))
implementation(project(":frontend:features:billing-feature")) implementation(project(":frontend:features:billing-feature"))
// Compose Desktop // Compose Desktop

View File

@ -5,12 +5,8 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.singleWindowApplication import androidx.compose.ui.window.singleWindowApplication
import at.mocode.turnier.feature.presentation.TurnierDetailScreen import at.mocode.frontend.features.pferde.presentation.PferdeScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen
/** /**
* Hot-Reload Preview Entry Point * Hot-Reload Preview Entry Point
@ -31,6 +27,13 @@ fun main() = singleWindowApplication(title = "🔥 Hot-Reload Preview") {
private fun PreviewContent() { private fun PreviewContent() {
MaterialTheme { MaterialTheme {
Surface { Surface {
// --- REITER ---
// ReiterScreen(viewModel = ReiterViewModel())
// --- PFERDE ---
PferdeScreen(viewModel = PferdeViewModel())
// ── Hier den gewünschten Screen eintragen ────────────────────── // ── Hier den gewünschten Screen eintragen ──────────────────────
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {}) // VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
// VeranstalterNeuScreen(onBack = {}, onSave = {}) // VeranstalterNeuScreen(onBack = {}, onSave = {})
@ -40,11 +43,11 @@ private fun PreviewContent() {
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// Standard: AdminUebersichtScreen (Startseite nach Login) // Standard: AdminUebersichtScreen (Startseite nach Login)
AdminUebersichtScreen( // AdminUebersichtScreen(
onVeranstalterAuswahl = {}, // onVeranstalterAuswahl = {},
onVeranstaltungOeffnen = {}, // onVeranstaltungOeffnen = {},
onPingService = {} // onPingService = {}
) // )
} }
} }
} }

View File

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