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.
* [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.
---

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.CardDefaults
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
@ -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)) {
MsEnumDropdown(
label = "Lizenzklasse",
options = LizenzKlasse.values(),
options = LizenzKlasse.entries.toTypedArray(),
selectedOption = uiState.editLizenz,
onOptionSelected = onLizenzChange,
optionLabel = { it.label },
@ -144,7 +144,7 @@ private fun ReiterEditorContent(
)
MsEnumDropdown(
label = "Hauptsparte",
options = Sparte.values(),
options = Sparte.entries.toTypedArray(),
selectedOption = uiState.editSparte,
onOptionSelected = onSparteChange,
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.
* In einem echten Szenario würden wir hier ein Repository injizieren.
*/
class ReiterViewModel {
open class ReiterViewModel(initialLoad: Boolean = true) {
var uiState by mutableStateOf(ReiterUiState())
private set
protected set
init {
// Initialer Load (Mock-Daten)
loadReiter()
if (initialLoad) {
// Initialer Load (Mock-Daten)
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.turnierFeature)
implementation(project(":frontend:features:profile-feature"))
implementation(project(":frontend:features:reiter-feature"))
implementation(project(":frontend:features:pferde-feature"))
implementation(project(":frontend:features:billing-feature"))
// Compose Desktop

View File

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

View File

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