Add results-service microservice with API gateway integration, implement Ergebnis repository and edit dialog, update BewerbViewModel for Ergebniserfassung, and enhance Turnier UI with result management features.
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
||||
package at.mocode.turnier.feature.domain
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Ergebnis(
|
||||
val id: String? = null,
|
||||
val nennungId: String,
|
||||
val bewerbId: String,
|
||||
val wertnote: Double? = null,
|
||||
val zeit: Double? = null,
|
||||
val fehler: Double? = null,
|
||||
val status: ErgebnisStatus = ErgebnisStatus.OK,
|
||||
val platzierung: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class ErgebnisStatus {
|
||||
OK, AUSGESCHIEDEN, VERZICHTET, DISQUALIFIZIERT, NICHT_GESTARTET
|
||||
}
|
||||
|
||||
interface ErgebnisRepository {
|
||||
suspend fun getForBewerb(bewerbId: String): Result<List<Ergebnis>>
|
||||
suspend fun save(ergebnis: Ergebnis): Result<Ergebnis>
|
||||
suspend fun calculatePlatzierung(bewerbId: String): Result<List<Ergebnis>>
|
||||
}
|
||||
+46
-1
@@ -28,6 +28,7 @@ data class StartlistenZeile(
|
||||
val reiter: String,
|
||||
val pferd: String,
|
||||
val wunsch: String,
|
||||
val nennungId: String = ""
|
||||
)
|
||||
|
||||
data class BewerbState(
|
||||
@@ -49,8 +50,11 @@ data class BewerbState(
|
||||
val isAuditLoading: Boolean = false,
|
||||
val exportContent: String? = null,
|
||||
val showExportDialog: Boolean = false,
|
||||
val ergebnisse: List<at.mocode.turnier.feature.domain.Ergebnis> = emptyList(),
|
||||
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
|
||||
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
|
||||
val editingErgebnis: at.mocode.turnier.feature.domain.Ergebnis? = null,
|
||||
val selectedZeile: StartlistenZeile? = null
|
||||
)
|
||||
|
||||
sealed interface BewerbIntent {
|
||||
@@ -79,12 +83,17 @@ sealed interface BewerbIntent {
|
||||
data class LoadAuditLog(val bewerbId: Long) : BewerbIntent
|
||||
data object ExportZnsBSatz : BewerbIntent
|
||||
data object CloseExportDialog : BewerbIntent
|
||||
data object LoadErgebnisse : BewerbIntent
|
||||
data class OpenErgebnisEdit(val zeile: StartlistenZeile) : BewerbIntent
|
||||
data object CloseErgebnisEdit : BewerbIntent
|
||||
data class SaveErgebnis(val ergebnis: at.mocode.turnier.feature.domain.Ergebnis) : BewerbIntent
|
||||
}
|
||||
|
||||
|
||||
class BewerbViewModel(
|
||||
private val repo: BewerbRepository,
|
||||
private val startlistenRepo: StartlistenRepository,
|
||||
private val ergebnisRepo: at.mocode.turnier.feature.domain.ErgebnisRepository,
|
||||
private val syncManager: SyncManager? = null,
|
||||
private val turnierId: Long,
|
||||
) {
|
||||
@@ -134,7 +143,12 @@ class BewerbViewModel(
|
||||
when (intent) {
|
||||
is BewerbIntent.Load, is BewerbIntent.Refresh -> load()
|
||||
is BewerbIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
|
||||
is BewerbIntent.Select -> reduce { it.copy(selectedId = intent.id) }
|
||||
is BewerbIntent.Select -> {
|
||||
reduce { it.copy(selectedId = intent.id) }
|
||||
if (intent.id != null) {
|
||||
loadErgebnisse()
|
||||
}
|
||||
}
|
||||
is BewerbIntent.ClearError -> reduce { it.copy(errorMessage = null) }
|
||||
|
||||
is BewerbIntent.OpenDialog -> {
|
||||
@@ -173,6 +187,37 @@ class BewerbViewModel(
|
||||
is BewerbIntent.LoadAuditLog -> loadAuditLog(intent.bewerbId)
|
||||
is BewerbIntent.ExportZnsBSatz -> exportZnsBSatz()
|
||||
is BewerbIntent.CloseExportDialog -> reduce { it.copy(showExportDialog = false, exportContent = null) }
|
||||
is BewerbIntent.LoadErgebnisse -> loadErgebnisse()
|
||||
is BewerbIntent.OpenErgebnisEdit -> {
|
||||
val bewerbId = state.value.selectedId?.toString() ?: ""
|
||||
reduce {
|
||||
it.copy(
|
||||
selectedZeile = intent.zeile,
|
||||
editingErgebnis = at.mocode.turnier.feature.domain.Ergebnis(
|
||||
nennungId = intent.zeile.nennungId,
|
||||
bewerbId = bewerbId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
is BewerbIntent.CloseErgebnisEdit -> reduce { it.copy(editingErgebnis = null, selectedZeile = null) }
|
||||
is BewerbIntent.SaveErgebnis -> {
|
||||
scope.launch {
|
||||
ergebnisRepo.save(intent.ergebnis).onSuccess {
|
||||
reduce { it.copy(editingErgebnis = null, selectedZeile = null) }
|
||||
loadErgebnisse()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadErgebnisse() {
|
||||
val bewerbId = state.value.selectedId ?: return
|
||||
scope.launch {
|
||||
ergebnisRepo.getForBewerb(bewerbId.toString()).onSuccess { list ->
|
||||
reduce { it.copy(ergebnisse = list) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package at.mocode.turnier.feature.data.remote
|
||||
|
||||
import at.mocode.frontend.core.network.ApiRoutes
|
||||
import at.mocode.turnier.feature.domain.Ergebnis
|
||||
import at.mocode.turnier.feature.domain.ErgebnisRepository
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
class DefaultErgebnisRepository(
|
||||
private val client: HttpClient
|
||||
) : ErgebnisRepository {
|
||||
|
||||
override suspend fun getForBewerb(bewerbId: String): Result<List<Ergebnis>> = runCatching {
|
||||
client.get(ApiRoutes.Results.bewerb(bewerbId)).body()
|
||||
}
|
||||
|
||||
override suspend fun save(ergebnis: Ergebnis): Result<Ergebnis> = runCatching {
|
||||
if (ergebnis.id == null) {
|
||||
client.post(ApiRoutes.Results.ROOT) {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ergebnis)
|
||||
}.body()
|
||||
} else {
|
||||
client.put("${ApiRoutes.Results.ROOT}/${ergebnis.id}") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ergebnis)
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun calculatePlatzierung(bewerbId: String): Result<List<Ergebnis>> = runCatching {
|
||||
client.post("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/calculate").body()
|
||||
}
|
||||
}
|
||||
+2
@@ -18,6 +18,7 @@ val turnierFeatureModule = module {
|
||||
single<StartlistenRepository> { DefaultStartlistenRepository(client = get(qualifier = named("apiClient"))) }
|
||||
single<at.mocode.turnier.feature.domain.NennungRepository> { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) }
|
||||
single<at.mocode.turnier.feature.domain.MasterdataRepository> { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) }
|
||||
single<at.mocode.turnier.feature.domain.ErgebnisRepository> { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) }
|
||||
|
||||
// ViewModels
|
||||
factory { TurnierViewModel(repo = get()) }
|
||||
@@ -26,6 +27,7 @@ val turnierFeatureModule = module {
|
||||
BewerbViewModel(
|
||||
repo = get(),
|
||||
startlistenRepo = get(),
|
||||
ergebnisRepo = get(),
|
||||
syncManager = getOrNull<SyncManager>(),
|
||||
turnierId = turnierId
|
||||
)
|
||||
|
||||
+71
-2
@@ -1,12 +1,81 @@
|
||||
package at.mocode.turnier.feature.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.turnier.feature.domain.Pferd
|
||||
import at.mocode.turnier.feature.domain.Reiter
|
||||
import at.mocode.turnier.feature.domain.Ergebnis
|
||||
import at.mocode.turnier.feature.domain.ErgebnisStatus
|
||||
|
||||
@Composable
|
||||
fun ErgebnisEditDialog(
|
||||
ergebnis: Ergebnis,
|
||||
reiterName: String,
|
||||
pferdName: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Ergebnis) -> Unit
|
||||
) {
|
||||
var wertnote by remember { mutableStateOf(ergebnis.wertnote?.toString() ?: "") }
|
||||
var zeit by remember { mutableStateOf(ergebnis.zeit?.toString() ?: "") }
|
||||
var fehler by remember { mutableStateOf(ergebnis.fehler?.toString() ?: "") }
|
||||
var status by remember { mutableStateOf(ergebnis.status) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Ergebnis erfassen: $reiterName mit $pferdName") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = wertnote,
|
||||
onValueChange = { wertnote = it },
|
||||
label = { Text("Wertnote") }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = zeit,
|
||||
onValueChange = { zeit = it },
|
||||
label = { Text("Zeit") }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = fehler,
|
||||
onValueChange = { fehler = it },
|
||||
label = { Text("Fehler") }
|
||||
)
|
||||
|
||||
Text("Status")
|
||||
Column {
|
||||
ErgebnisStatus.entries.forEach { s ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = status == s, onClick = { status = s })
|
||||
Text(s.name, modifier = Modifier.clickable { status = s })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
onSave(ergebnis.copy(
|
||||
wertnote = wertnote.toDoubleOrNull(),
|
||||
zeit = zeit.toDoubleOrNull(),
|
||||
fehler = fehler.toDoubleOrNull(),
|
||||
status = status
|
||||
))
|
||||
}) {
|
||||
Text("Speichern")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReiterEditDialog(
|
||||
|
||||
+46
-18
@@ -11,6 +11,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.turnier.feature.domain.Bewerb
|
||||
import at.mocode.turnier.feature.domain.Ergebnis
|
||||
import at.mocode.turnier.feature.presentation.StartlistenZeile
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
private val ElBlue = Color(0xFF1E3A8A)
|
||||
@@ -35,7 +37,9 @@ fun ErgebnislistenTabContent(
|
||||
ErgebnislistenBewerbsTabs(
|
||||
bewerbe = state.list,
|
||||
selectedId = state.selectedId,
|
||||
onSelect = { viewModel.send(BewerbIntent.Select(it)) }
|
||||
onSelect = { viewModel.send(BewerbIntent.Select(it)) },
|
||||
ergebnisse = state.ergebnisse,
|
||||
startliste = state.currentStartliste
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +54,9 @@ fun ErgebnislistenTabContent(
|
||||
private fun ErgebnislistenBewerbsTabs(
|
||||
bewerbe: List<Bewerb>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long?) -> Unit
|
||||
onSelect: (Long?) -> Unit,
|
||||
ergebnisse: List<Ergebnis>,
|
||||
startliste: List<StartlistenZeile>
|
||||
) {
|
||||
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
|
||||
|
||||
@@ -121,25 +127,47 @@ private fun ErgebnislistenBewerbsTabs(
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
// Leere Liste
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Keine Ergebnisse vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Ergebnisse werden nach dem Turnier eingetragen.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {}) {
|
||||
Text("Ergebnisse importieren", fontSize = 13.sp)
|
||||
}
|
||||
Button(
|
||||
onClick = {},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
|
||||
) {
|
||||
Text("Ergebnisse eingeben", fontSize = 13.sp)
|
||||
if (ergebnisse.isEmpty()) {
|
||||
// Leere Liste
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Keine Ergebnisse vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Ergebnisse werden nach dem Turnier eingetragen.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {}) {
|
||||
Text("Ergebnisse importieren", fontSize = 13.sp)
|
||||
}
|
||||
Button(
|
||||
onClick = {},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
|
||||
) {
|
||||
Text("Ergebnisse eingeben", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
androidx.compose.foundation.lazy.LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(ergebnisse.size) { index ->
|
||||
val erg = ergebnisse[index]
|
||||
val zeile = startliste.find { it.nennungId == erg.nennungId }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(erg.platzierung?.let { "$it." } ?: "-", fontSize = 12.sp, modifier = Modifier.width(50.dp), fontWeight = FontWeight.Bold, color = ElBlue)
|
||||
Text(zeile?.nr?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(65.dp))
|
||||
Text(zeile?.pferd ?: "Unbekannt", fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
Text(zeile?.reiter ?: "Unbekannt", fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
Text(erg.fehler?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(60.dp))
|
||||
Text(erg.zeit?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(70.dp))
|
||||
Text(erg.wertnote?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(70.dp))
|
||||
}
|
||||
HorizontalDivider(color = Color(0xFFE5E7EB))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -1,6 +1,7 @@
|
||||
package at.mocode.turnier.feature.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -38,7 +39,8 @@ fun StartlistenTabContent(
|
||||
selectedId = state.selectedId,
|
||||
onSelect = { viewModel.send(BewerbIntent.Select(it)) },
|
||||
currentStartliste = state.currentStartliste,
|
||||
onGenerate = { viewModel.generateStartliste() }
|
||||
onGenerate = { viewModel.generateStartliste() },
|
||||
onRowClick = { viewModel.send(BewerbIntent.OpenErgebnisEdit(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,6 +49,20 @@ fun StartlistenTabContent(
|
||||
// ── Rechte Spalte: Sortierung & Zeit ─────────────────────────────────
|
||||
StartlistenSortierPanel(modifier = Modifier.width(280.dp).fillMaxHeight())
|
||||
}
|
||||
|
||||
// Ergebnis-Dialog
|
||||
state.editingErgebnis?.let { ergebnis ->
|
||||
val zeile = state.selectedZeile
|
||||
if (zeile != null) {
|
||||
ErgebnisEditDialog(
|
||||
ergebnis = ergebnis,
|
||||
reiterName = zeile.reiter,
|
||||
pferdName = zeile.pferd,
|
||||
onDismiss = { viewModel.send(BewerbIntent.CloseErgebnisEdit) },
|
||||
onSave = { viewModel.send(BewerbIntent.SaveErgebnis(it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -55,7 +71,8 @@ private fun StartlistenBewerbsTabs(
|
||||
selectedId: Long?,
|
||||
onSelect: (Long?) -> Unit,
|
||||
currentStartliste: List<StartlistenZeile>,
|
||||
onGenerate: () -> Unit
|
||||
onGenerate: () -> Unit,
|
||||
onRowClick: (StartlistenZeile) -> Unit
|
||||
) {
|
||||
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
|
||||
|
||||
@@ -139,7 +156,10 @@ private fun StartlistenBewerbsTabs(
|
||||
items(currentStartliste.size) { index ->
|
||||
val zeile = currentStartliste[index]
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onRowClick(zeile) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(zeile.nr.toString(), fontSize = 12.sp, modifier = Modifier.width(70.dp))
|
||||
|
||||
-2
@@ -3,9 +3,7 @@ package at.mocode.frontend.features.verein.di
|
||||
import at.mocode.frontend.features.verein.data.KtorVereinRepository
|
||||
import at.mocode.frontend.features.verein.domain.VereinRepository
|
||||
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val vereinFeatureModule = module {
|
||||
|
||||
Reference in New Issue
Block a user