Extend Bewerb table and add BewerbRichterEinsatz table: introduce new properties, indexes, and mappings to support richer domain model and scheduling capabilities.

This commit is contained in:
2026-04-08 23:00:16 +02:00
parent 84403287a1
commit bb4b5924d1
@@ -0,0 +1,321 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.turnier.feature.data.remote.CreateBewerbPayload
import at.mocode.turnier.feature.data.remote.RichterEinsatzDto
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
enum class WizardStep { IDENTIFIKATION, DETAILS_FINANZEN, ORT_ZEIT, RICHTER_TEILUNG }
data class CreateBewerbWizardState(
// Step 1
val klasse: String = "",
val hoeheCm: String = "", // UI-Text, wird zu Int? geparst
val bezeichnung: String = "",
// Step 2
val beschreibung: String = "",
val aufgabe: String = "",
val startgeld: String = "", // UI-Text, wird zu Long? Cent
val geldpreisAusbezahlt: Boolean = false,
// Step 3
val austragungsplatzId: String = "",
val beginnZeitTyp: String = "", // FIX / ANSCHLIESSEND
val geplantesDatum: String = "", // yyyy-MM-dd
val beginnZeit: String = "", // HH:mm
val reitdauerMinuten: String = "",
val umbauMinuten: String = "",
val besichtigungMinuten: String = "",
val stechenGeplant: Boolean = false,
// Step 4
val richter: List<RichterEinsatzDto> = emptyList(),
val teilungsTyp: String = "", // Hinweis: aktuell nur UI; Backend-Feld folgt separat
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateBewerbWizardScreen(
modifier: Modifier = Modifier,
state: CreateBewerbWizardState,
onStateChange: (CreateBewerbWizardState) -> Unit,
onSubmit: (CreateBewerbPayload) -> Unit,
) {
var selectedTab by remember { mutableStateOf(0) }
val steps = WizardStep.values()
Column(modifier.fillMaxSize().padding(16.dp)) {
Text("Neuen Bewerb anlegen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
TabRow(selectedTabIndex = selectedTab) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Identifikation") })
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }, text = { Text("Details & Finanzen") })
Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }, text = { Text("Ort & Zeitplan") })
Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }, text = { Text("Richter & Teilung") })
}
Divider(Modifier.padding(vertical = 8.dp))
when (steps[selectedTab]) {
WizardStep.IDENTIFIKATION -> StepIdentifikation(state, onStateChange)
WizardStep.DETAILS_FINANZEN -> StepDetailsFinanzen(state, onStateChange)
WizardStep.ORT_ZEIT -> StepOrtZeit(state, onStateChange)
WizardStep.RICHTER_TEILUNG -> StepRichterTeilung(state, onStateChange)
}
Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(enabled = selectedTab > 0, onClick = { selectedTab-- }) { Text("Zurück") }
Spacer(Modifier.weight(1f))
if (selectedTab < steps.lastIndex) {
TextButton(onClick = { selectedTab++ }) { Text("Weiter") }
} else {
OutlinedButton(onClick = {
val payload = state.toPayloadOrNull()
if (payload != null) onSubmit(payload)
}) { Text("Bewerb anlegen") }
}
}
}
}
@Composable
private fun StepIdentifikation(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
OutlinedTextField(
value = state.klasse,
onValueChange = { onStateChange(state.copy(klasse = it)) },
label = { Text("Sparte/Kategorie/Klasse") },
modifier = Modifier.fillMaxWidth()
)
}
item {
OutlinedTextField(
value = state.hoeheCm,
onValueChange = { onStateChange(state.copy(hoeheCm = it.filter { ch -> ch.isDigit() })) },
label = { Text("Höhe (cm)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
OutlinedTextField(
value = state.bezeichnung,
onValueChange = { onStateChange(state.copy(bezeichnung = it)) },
label = { Text("Bezeichnung") },
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun StepDetailsFinanzen(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
OutlinedTextField(
value = state.beschreibung,
onValueChange = { onStateChange(state.copy(beschreibung = it)) },
label = { Text("Beschreibung (optional)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
OutlinedTextField(
value = state.aufgabe,
onValueChange = { onStateChange(state.copy(aufgabe = it)) },
label = { Text("Aufgabe (z.B. R1)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
OutlinedTextField(
value = state.startgeld,
onValueChange = { onStateChange(state.copy(startgeld = it.filter { ch -> ch.isDigit() })) },
label = { Text("Startgeld (Cent)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = state.geldpreisAusbezahlt, onCheckedChange = { onStateChange(state.copy(geldpreisAusbezahlt = it)) })
Text("Geldpreis ausbezahlt")
}
}
}
}
@Composable
private fun StepOrtZeit(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
OutlinedTextField(
value = state.austragungsplatzId,
onValueChange = { onStateChange(state.copy(austragungsplatzId = it)) },
label = { Text("Austragungsplatz-ID (optional)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
OutlinedTextField(
value = state.beginnZeitTyp,
onValueChange = { onStateChange(state.copy(beginnZeitTyp = it)) },
label = { Text("Beginn (FIX/ANSCHLIESSEND)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = state.geplantesDatum,
onValueChange = { onStateChange(state.copy(geplantesDatum = it)) },
label = { Text("Datum (yyyy-MM-dd)") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.beginnZeit,
onValueChange = { onStateChange(state.copy(beginnZeit = it)) },
label = { Text("Beginn (HH:mm)") },
modifier = Modifier.weight(1f)
)
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = state.reitdauerMinuten,
onValueChange = { onStateChange(state.copy(reitdauerMinuten = it.filter { ch -> ch.isDigit() })) },
label = { Text("Reitdauer (min)") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.umbauMinuten,
onValueChange = { onStateChange(state.copy(umbauMinuten = it.filter { ch -> ch.isDigit() })) },
label = { Text("Umbau (min)") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.besichtigungMinuten,
onValueChange = { onStateChange(state.copy(besichtigungMinuten = it.filter { ch -> ch.isDigit() })) },
label = { Text("Besichtigung (min)") },
modifier = Modifier.weight(1f)
)
}
}
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = state.stechenGeplant, onCheckedChange = { onStateChange(state.copy(stechenGeplant = it)) })
Text("Stechen geplant")
}
}
}
}
@Composable
private fun StepRichterTeilung(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
Column(Modifier.fillMaxWidth()) {
// Warn-Logik (mock): Wenn Richter ausgewählt und Position = "C" ohne weiterer Prüfung -> TB-Hinweis
val warnTb = state.richter.isNotEmpty()
if (warnTb) {
Box(
Modifier.fillMaxWidth().background(Color(0xFFFFF8E1)).padding(12.dp)
) { Text("Hinweis: Richter-Zuweisung erfordert Freigabe durch TB (Qualifikation prüfen)", color = Color(0xFFFFA000)) }
Spacer(Modifier.height(8.dp))
}
OutlinedTextField(
value = state.teilungsTyp,
onValueChange = { onStateChange(state.copy(teilungsTyp = it)) },
label = { Text("Teilungsregel (z.B. MANUELL)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
// Minimal-UI für das Hinzufügen eines Richters (freie Eingabe von UUID + Position)
var funktionaerId by remember { mutableStateOf("") }
var position by remember { mutableStateOf("") }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(funktionaerId, { funktionaerId = it }, label = { Text("Funktionär-ID") }, modifier = Modifier.weight(1f))
OutlinedTextField(position, { position = it }, label = { Text("Position (C/M/…)") }, modifier = Modifier.weight(1f))
TextButton(onClick = {
if (funktionaerId.isNotBlank() && position.isNotBlank()) {
val list = state.richter + RichterEinsatzDto(funktionaerId = funktionaerId.trim(), position = position.trim())
onStateChange(state.copy(richter = list))
funktionaerId = ""; position = ""
}
}) { Text("Hinzufügen") }
}
Spacer(Modifier.height(8.dp))
state.richter.forEachIndexed { idx, r ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${idx + 1}. ${r.position} ${r.funktionaerId}")
TextButton(onClick = {
val list = state.richter.toMutableList().also { it.removeAt(idx) }
onStateChange(state.copy(richter = list))
}) { Text("Entfernen") }
}
}
}
}
// --- Mapping UI-State -> API-Payload ---
private fun CreateBewerbWizardState.toPayloadOrNull(): CreateBewerbPayload? {
if (klasse.isBlank() || bezeichnung.isBlank()) return null
val hoehe: Int? = hoeheCm.toIntOrNull()
val startgeldCent: Long? = startgeld.toLongOrNull()
val datum: LocalDate? = runCatching { if (geplantesDatum.isBlank()) null else LocalDate.parse(geplantesDatum) }.getOrNull()
val zeit: LocalTime? = runCatching { if (beginnZeit.isBlank()) null else LocalTime.parse(beginnZeit) }.getOrNull()
val beginnTyp: String? = beginnZeitTyp.ifBlank { null }
val reitMin = reitdauerMinuten.toIntOrNull()
val umbauMin = umbauMinuten.toIntOrNull()
val besMin = besichtigungMinuten.toIntOrNull()
return CreateBewerbPayload(
klasse = klasse.trim(),
hoeheCm = hoehe,
bezeichnung = bezeichnung.trim(),
beschreibung = beschreibung.ifBlank { null },
aufgabe = aufgabe.ifBlank { null },
aufgabenNummer = null,
paraGrade = null,
austragungsplatzId = austragungsplatzId.ifBlank { null },
richterEinsaetze = richter,
geplantesDatum = datum,
beginnZeitTyp = beginnTyp,
beginnZeit = zeit,
reitdauerMinuten = reitMin,
umbauMinuten = umbauMin,
besichtigungMinuten = besMin,
stechenGeplant = stechenGeplant,
startgeldCent = startgeldCent,
geldpreisAusbezahlt = geldpreisAusbezahlt,
)
}