Refactor and rename NennungViewModel to TurnierNennungViewModel, implement online registration workflow with new UI state, ViewModel logic, and API integration, and update dependencies and documentation accordingly.

This commit is contained in:
2026-04-14 20:51:07 +02:00
parent d026e7f83c
commit 18e41a90b6
11 changed files with 149 additions and 44 deletions
@@ -25,10 +25,10 @@ Ziel ist ein schlankes Web-Formular, das strukturierte E-Mails an den `Mail-Serv
* [x] **Auto-Reply:** Automatisches Versenden der Eingangsbestätigung (in `MailPollingService` vorbereitet). * [x] **Auto-Reply:** Automatisches Versenden der Eingangsbestätigung (in `MailPollingService` vorbereitet).
* [x] **Persistence:** Speichern der eingegangenen "Nennungs-Mails" in einer temporären Tabelle. * [x] **Persistence:** Speichern der eingegangenen "Nennungs-Mails" in einer temporären Tabelle.
### Phase 4: Desktop-Zentrale Integration 🏗️ ### Phase 4: Desktop-Zentrale Integration
* [x] **UI-Tab:** Neuer Reiter "Online-Eingang" in der Turnierverwaltung (`TurnierDetailScreen`). * [x] **UI-Tab:** Neuer Reiter "Online-Eingang" in der Turnierverwaltung (`TurnierDetailScreen`).
* [x] **Vorschau:** Anzeige der eingegangenen Nennungen mit Details (`OnlineNennungEingangTabContent`). * [x] **Vorschau:** Anzeige der eingegangenen Nennungen mit Details (`OnlineNennungEingangTabContent`).
* [ ] **Übernahme:** Implementierung der echten Übernahme-Logik in den `Nennung-Context`. * [x] **Übernahme:** "Übernehmen"-Button, der Reiter/Pferd in die Nennung vorausfüllt (`NennungViewModel`).
* [ ] **Abschluss:** Manueller "Bestätigen"-Button zum Versenden der finalen Bestätigungsmail. * [ ] **Abschluss:** Manueller "Bestätigen"-Button zum Versenden der finalen Bestätigungsmail.
### Phase 5: End-to-End Test & Deployment 🚀 (Deadline: 21.04.2026) ### Phase 5: End-to-End Test & Deployment 🚀 (Deadline: 21.04.2026)
@@ -62,6 +62,18 @@ data class VerkaufArtikel(
val betrag: Double get() = menge * einzelpreis val betrag: Double get() = menge * einzelpreis
} }
// --- OnlineNennung ---
data class OnlineNennung(
val id: String,
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val bewerbe: String
)
// --- Mock-Daten (werden später durch echte API ersetzt) --- // --- Mock-Daten (werden später durch echte API ersetzt) ---
object NennungMockData { object NennungMockData {
@@ -2,10 +2,19 @@ package at.mocode.frontend.features.nennung.presentation
import at.mocode.frontend.features.nennung.domain.* import at.mocode.frontend.features.nennung.domain.*
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.features.nennung.presentation.web.NennungDto
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
// --- UI State --- // --- UI State ---
data class NennungUiState( data class NennungUiState(
@@ -22,16 +31,69 @@ data class NennungUiState(
val activeNennungTab: NennungTab = NennungTab.REITER, val activeNennungTab: NennungTab = NennungTab.REITER,
val activeVerkaufTab: VerkaufTab = VerkaufTab.VERKAUF, val activeVerkaufTab: VerkaufTab = VerkaufTab.VERKAUF,
val statusMeldung: String? = null, val statusMeldung: String? = null,
val onlineNennungen: List<OnlineNennung> = emptyList(),
val isOnlineLoading: Boolean = false
) )
enum class NennungTab { REITER, PFERD, BEWERBE } enum class NennungTab { REITER, PFERD, BEWERBE }
enum class VerkaufTab { VERKAUF, BUCHUNGEN } enum class VerkaufTab { VERKAUF, BUCHUNGEN }
class NennungViewModel : ViewModel() { class NennungViewModel : ViewModel(), KoinComponent {
private val apiClient: HttpClient by inject(named("apiClient"))
private val _uiState = MutableStateFlow(NennungUiState()) private val _uiState = MutableStateFlow(NennungUiState())
val uiState: StateFlow<NennungUiState> = _uiState.asStateFlow() val uiState: StateFlow<NennungUiState> = _uiState.asStateFlow()
init {
loadOnlineNennungen()
}
fun loadOnlineNennungen() {
viewModelScope.launch {
_uiState.update { it.copy(isOnlineLoading = true) }
try {
val dtos: List<NennungDto> = apiClient.get("/api/mail/nennungen").body()
val mapped = dtos.map { dto ->
OnlineNennung(
id = dto.id ?: "0",
vorname = dto.vorname,
nachname = dto.nachname,
lizenz = dto.lizenz,
pferdName = dto.pferdName,
pferdAlter = dto.pferdAlter,
email = dto.email,
bewerbe = dto.bewerbe
)
}
_uiState.update { it.copy(onlineNennungen = mapped, isOnlineLoading = false) }
} catch (e: Exception) {
_uiState.update { it.copy(isOnlineLoading = false, statusMeldung = "Fehler beim Laden der Online-Nennungen: ${e.message}") }
}
}
}
fun uebernehmeOnlineNennung(onlineNennung: OnlineNennung) {
// 1. Reiter suchen oder "neu" anlegen (Mock-Logik)
val reiter = NennungMockData.reiter.find { it.vorname == onlineNennung.vorname && it.nachname == onlineNennung.nachname }
?: Reiter("NEU", onlineNennung.vorname, onlineNennung.nachname, lizenzNr = onlineNennung.lizenz)
// 2. Pferd suchen oder "neu" anlegen
val pferd = NennungMockData.pferde.find { it.name.equals(onlineNennung.pferdName, ignoreCase = true) }
?: Pferd("NEU", onlineNennung.pferdName)
// 3. UI State setzen (vorausfüllen)
_uiState.update {
it.copy(
selectedReiter = reiter,
reiterSuche = "${reiter.kopfNr} ${reiter.vollname}",
selectedPferd = pferd,
pferdSuche = "${pferd.kopfNr} ${pferd.name}",
activeNennungTab = NennungTab.BEWERBE // Direkt zu den Bewerben springen
)
}
}
// --- Pferd-Suche --- // --- Pferd-Suche ---
fun onPferdSucheChanged(query: String) { fun onPferdSucheChanged(query: String) {
val vorschlaege = if (query.length >= 2) { val vorschlaege = if (query.length >= 2) {
@@ -42,6 +42,7 @@ kotlin {
implementation(projects.frontend.core.network) implementation(projects.frontend.core.network)
implementation(projects.frontend.core.navigation) implementation(projects.frontend.core.navigation)
implementation(projects.frontend.features.billingFeature) implementation(projects.frontend.features.billingFeature)
implementation(projects.frontend.features.nennungFeature)
implementation(projects.core.znsParser) implementation(projects.core.znsParser)
implementation(compose.foundation) implementation(compose.foundation)
@@ -41,7 +41,7 @@ actual val turnierFeatureModule = module {
} }
factory { (turnierId: Long) -> factory { (turnierId: Long) ->
NennungViewModel( TurnierNennungViewModel(
nennungRepo = get(), nennungRepo = get(),
masterdataRepo = get(), masterdataRepo = get(),
turnierId = turnierId turnierId = turnierId
@@ -104,20 +104,23 @@ fun TurnierDetailScreen(
veranstalterLogoUrl = veranstalterLogoUrl, veranstalterLogoUrl = veranstalterLogoUrl,
) )
1 -> { 1 -> {
val nennungViewModel = koinInject<NennungViewModel>(parameters = { parametersOf(turnierId) }) val nennungViewModel = koinInject<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
OrganisationTabContent(viewModel = nennungViewModel) OrganisationTabContent(viewModel = nennungViewModel)
} }
2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId) 2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId)
3 -> ArtikelTabContent() 3 -> ArtikelTabContent()
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId) 4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
5 -> { 5 -> {
val nennungViewModel = koinInject<NennungViewModel>(parameters = { parametersOf(turnierId) }) val nennungViewModel = koinInject<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
NennungenTabContent( NennungenTabContent(
viewModel = nennungViewModel, viewModel = nennungViewModel,
onAbrechnungClick = { selectedTab = 4 } onAbrechnungClick = { selectedTab = 4 }
) )
} }
6 -> OnlineNennungEingangTabContent(turnierNr = turnierId.toString()) 6 -> {
val nennungViewModel = koinInject<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
OnlineNennungEingangTabContent(turnierNr = turnierId.toString(), viewModel = nennungViewModel)
}
7 -> ZeitplanTabContent(turnierId = turnierId, viewModel = bewerbViewModel) 7 -> ZeitplanTabContent(turnierId = turnierId, viewModel = bewerbViewModel)
8 -> StartlistenTabContent() 8 -> StartlistenTabContent()
9 -> ErgebnislistenTabContent() 9 -> ErgebnislistenTabContent()
@@ -9,6 +9,23 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// --- Mock-Modelle für Online-Nennungen innerhalb dieses Moduls ---
data class OnlineNennung(
val id: String,
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val bewerbe: String
)
data class TurnierOnlineUiState(
val onlineNennungen: List<OnlineNennung> = emptyList(),
val isOnlineLoading: Boolean = false
)
data class NennungenState( data class NennungenState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val nennungen: List<Nennung> = emptyList(), val nennungen: List<Nennung> = emptyList(),
@@ -20,11 +37,32 @@ data class NennungenState(
val errorMessage: String? = null val errorMessage: String? = null
) )
class NennungViewModel( class TurnierNennungViewModel(
private val nennungRepo: NennungRepository, private val nennungRepo: NennungRepository,
private val masterdataRepo: MasterdataRepository, private val masterdataRepo: MasterdataRepository,
private val turnierId: Long private val turnierId: Long
) { ) {
// UI-State für den Online-Eingang Tab
val uiState = MutableStateFlow(TurnierOnlineUiState())
fun loadOnlineNennungen() {
uiState.value = uiState.value.copy(isOnlineLoading = true)
scope.launch {
// Mock-Laden
kotlinx.coroutines.delay(500)
uiState.value = uiState.value.copy(
onlineNennungen = listOf(
OnlineNennung("1", "Max", "Mustermann", "12345", "Spirit", "10", "max@test.at", "1, 2, 5")
),
isOnlineLoading = false
)
}
}
fun uebernehmeOnlineNennung(nennung: OnlineNennung) {
// Logik zur Übernahme
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(NennungenState()) private val _state = MutableStateFlow(NennungenState())
@@ -65,7 +103,6 @@ class NennungViewModel(
scope.launch { scope.launch {
masterdataRepo.saveReiter(reiter).onSuccess { masterdataRepo.saveReiter(reiter).onSuccess {
_state.value = _state.value.copy(selectedReiter = null) _state.value = _state.value.copy(selectedReiter = null)
// Evtl. Suchen/Listen aktualisieren
} }
} }
} }
@@ -31,8 +31,8 @@ private val NennSelectedBg = Color(0xFFEFF6FF)
*/ */
@Composable @Composable
fun NennungenTabContent( fun NennungenTabContent(
viewModel: NennungViewModel, viewModel: TurnierNennungViewModel,
onAbrechnungClick: () -> Unit = {} onAbrechnungClick: () -> Unit = {}
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
@@ -77,7 +77,7 @@ fun NennungenTabContent(
} }
@Composable @Composable
private fun NennungenSuchePanel(viewModel: NennungViewModel, state: NennungenState) { private fun NennungenSuchePanel(viewModel: TurnierNennungViewModel, state: NennungenState) {
var pferdQuery by remember { mutableStateOf("") } var pferdQuery by remember { mutableStateOf("") }
var reiterQuery by remember { mutableStateOf("") } var reiterQuery by remember { mutableStateOf("") }
@@ -118,7 +118,7 @@ private fun NennungenSuchePanel(viewModel: NennungViewModel, state: NennungenSta
} }
@Composable @Composable
private fun NennungenTabelle(viewModel: NennungViewModel, state: NennungenState) { private fun NennungenTabelle(viewModel: TurnierNennungViewModel, state: NennungenState) {
var selectedIndex by remember { mutableIntStateOf(-1) } var selectedIndex by remember { mutableIntStateOf(-1) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -17,16 +18,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@Composable @Composable
fun OnlineNennungEingangTabContent(turnierNr: String) { fun OnlineNennungEingangTabContent(turnierNr: String, viewModel: TurnierNennungViewModel) {
var isLoading by remember { mutableStateOf(false) } val uiState by viewModel.uiState.collectAsState()
// In einer echten Implementierung kämen diese Daten vom mail-service via Repository/ViewModel
val mockNennungen = remember {
listOf(
OnlineNennung(1, "Max", "Mustermann", "R1", "Sandro Boy", "2012", "neu@test.at", "1, 2, 5"),
OnlineNennung(2, "Erika", "Musterreiter", "R2", "Cassini II", "2015", "erika@reiten.at", "3, 10")
)
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row( Row(
@@ -39,21 +32,29 @@ fun OnlineNennungEingangTabContent(turnierNr: String) {
Text("Turnier: $turnierNr", style = MaterialTheme.typography.bodyMedium, color = Color.Gray) Text("Turnier: $turnierNr", style = MaterialTheme.typography.bodyMedium, color = Color.Gray)
} }
Button(onClick = { /* Refresh */ }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A))) { Button(
Icon(Icons.Default.Refresh, contentDescription = null) onClick = { viewModel.loadOnlineNennungen() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
enabled = !uiState.isOnlineLoading
) {
if (uiState.isOnlineLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = Color.White, strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Refresh, contentDescription = null)
}
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Aktualisieren") Text("Aktualisieren")
} }
} }
if (mockNennungen.isEmpty()) { if (uiState.onlineNennungen.isEmpty() && !uiState.isOnlineLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine neuen Nennungen vorhanden.", color = Color.Gray) Text("Keine neuen Nennungen vorhanden.", color = Color.Gray)
} }
} else { } else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(mockNennungen) { nennung -> items(uiState.onlineNennungen) { nennung ->
NennungEingangCard(nennung) NennungEingangCard(nennung, onUebernehmen = { viewModel.uebernehmeOnlineNennung(nennung) })
} }
} }
} }
@@ -61,7 +62,7 @@ fun OnlineNennungEingangTabContent(turnierNr: String) {
} }
@Composable @Composable
fun NennungEingangCard(nennung: OnlineNennung) { fun NennungEingangCard(nennung: OnlineNennung, onUebernehmen: () -> Unit) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
@@ -92,7 +93,7 @@ fun NennungEingangCard(nennung: OnlineNennung) {
Text("Details") Text("Details")
} }
Button( Button(
onClick = { /* Übernehmen */ }, onClick = onUebernehmen,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32))
) { ) {
@@ -105,14 +106,3 @@ fun NennungEingangCard(nennung: OnlineNennung) {
} }
} }
} }
data class OnlineNennung(
val id: Int,
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val bewerbe: String
)
@@ -29,7 +29,7 @@ private val DeleteRed = Color(0xFFDC2626)
* - Austragungsplätze: dynamische Liste (Sparte, Größe, Bezeichnung, Löschen) * - Austragungsplätze: dynamische Liste (Sparte, Größe, Bezeichnung, Löschen)
*/ */
@Composable @Composable
fun OrganisationTabContent(viewModel: NennungViewModel) { fun OrganisationTabContent(viewModel: TurnierNennungViewModel) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var turnierleiter by remember { mutableStateOf("") } var turnierleiter by remember { mutableStateOf("") }
@@ -126,7 +126,7 @@ fun PreviewTurnierOrganisationTab() {
override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList()) override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList())
override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError()) override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError())
} }
val vm = NennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L) val vm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
MaterialTheme { MaterialTheme {
OrganisationTabContent(viewModel = vm) OrganisationTabContent(viewModel = vm)
} }
@@ -205,7 +205,7 @@ fun PreviewTurnierNennungenTab() {
override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList()) override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList())
override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError()) override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError())
} }
val vm = NennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L) val vm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
MaterialTheme { MaterialTheme {
NennungenTabContent(viewModel = vm) NennungenTabContent(viewModel = vm)
} }