Add Zeitplan fields to domain and DTO models, implement UpdateZeitplan intent and API integration, and update ViewModel for Zeitplan state consistency.

This commit is contained in:
Stefan Mogeritsch 2026-04-11 20:42:36 +02:00
parent 52bc8f3fbe
commit bc46054412
9 changed files with 108 additions and 25 deletions

View File

@ -3,26 +3,27 @@
## 📅 Session Info
- **Datum:** 2026-04-11
- **Agenten:** 🏗️ Lead Architect, 👷 Backend Developer, 🎨 Frontend Expert, 🧹 Curator
- **Fokus:** Implementierung Zeitplan-Optimierung (Frontend Prototyp)
- **Fokus:** Integration Zeitplan-Optimierung & Datenanbindung
## 🏗️ Architektur-Entscheidungen
- **Komponente:** `TurnierZeitplanTab.kt` wurde als zentraler Ort für die visuelle Zeitplanung geschaffen.
- **Layout:** 14-Stunden-Raster (07:00 - 21:00) mit feststehender Zeitachse und scrollbarem Gitter.
- **Interaktion:** Vertikales Drag & Drop mit 5-Minuten-Snapping zur präzisen Planung.
- **Datenfluss:** `TurnierZeitplanTab.kt` wurde erfolgreich an das `BewerbViewModel` angebunden.
- **DI:** Das `BewerbViewModel` wird nun zentral im `TurnierDetailScreen` via Koin injiziert und an die Tabs (Bewerbe & Zeitplan) verteilt, um State-Konsistenz zu gewährleisten.
- **Domäne:** Das Domänenmodell `Bewerb` im Frontend wurde um Zeitplan-Felder (`beginnZeit`, `geplantesDatum`, etc.) erweitert, um das Mapping zum Backend zu vervollständigen.
## 👷 Backend/Integration (Status)
- **Backend:** Logik in `BewerbService` bereits vorhanden (vorherige Sessions).
- **Frontend-Store:** Aktuell noch Mock-Daten (`ZeitplanItemUi`), Anbindung an `BewerbViewModel` steht noch aus.
## 👷 Backend/Integration
- **API:** Unterstützung für `PATCH /bewerbe/{id}/zeitplan` im `DefaultBewerbRepository` implementiert.
- **ViewModel:** Neuer Intent `BewerbIntent.UpdateZeitplan` zur persistierung von Zeitänderungen.
## 🎨 Frontend (Details)
- **Modul:** `frontend:features:turnier-feature`
- **Datei:** `TurnierZeitplanTab.kt` (Neu)
- **Anpassung:** `TurnierDetailScreen.kt` um Tab "ZEITPLAN" erweitert.
- **Mapping:** Automatisches Mapping von `Bewerb` (Domain) auf `ZeitplanItemUi` (Visual) inkl. dynamischer Farbwahl nach Sparte.
- **Interaktion:** Drag & Drop Änderungen triggern nun echte API-Calls und laden den State neu.
- **UI:** Integration des "Bewerbe"-Tabs im `TurnierDetailScreen` vervollständigt (war vorher ein Platzhalter).
## 🧹 Curator Status & Cleanup
- ✅ Neue UI-Komponente erstellt und syntaktisch korrigiert.
- ✅ Navigation im Turnier-Detail angepasst.
- 📂 Nächster Schritt: Mapping von `BewerbUiModel` auf `ZeitplanItemUi`.
- ✅ Datenmodelle und Mapper erweitert.
- ✅ Repository-Anbindung vervollständigt.
- ✅ ViewModel-Integration im UI-Layer abgeschlossen.
- 📂 Nächster Schritt: Implementierung der automatischen Konfliktprüfung im Zeitplan (Rulebook-Validierung).
---
*Erstellt durch den Curator.*

View File

@ -18,7 +18,13 @@ fun BewerbDto.toDomain(): Bewerb = Bewerb(
name = name,
sparte = sparte,
klasse = klasse,
nennungen = nennungen
nennungen = nennungen,
geplantesDatum = geplantesDatum,
beginnZeit = beginnZeit,
reitdauerMinuten = reitdauerMinuten,
umbauMinuten = umbauMinuten,
besichtigungMinuten = besichtigungMinuten,
austragungsplatzId = austragungsplatzId,
)
fun Bewerb.toDto(): BewerbDto = BewerbDto(
@ -29,7 +35,13 @@ fun Bewerb.toDto(): BewerbDto = BewerbDto(
name = name,
sparte = sparte,
klasse = klasse,
nennungen = nennungen
nennungen = nennungen,
geplantesDatum = geplantesDatum,
beginnZeit = beginnZeit,
reitdauerMinuten = reitdauerMinuten,
umbauMinuten = umbauMinuten,
besichtigungMinuten = besichtigungMinuten,
austragungsplatzId = austragungsplatzId,
)
fun AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name)

View File

@ -18,6 +18,12 @@ data class BewerbDto(
val sparte: String,
val klasse: String,
val nennungen: Int,
val geplantesDatum: String? = null,
val beginnZeit: String? = null,
val reitdauerMinuten: Int? = null,
val umbauMinuten: Int? = null,
val besichtigungMinuten: Int? = null,
val austragungsplatzId: String? = null,
)
@Serializable

View File

@ -10,6 +10,13 @@ data class Bewerb(
val klasse: String,
val nennungen: Int,
val warnungen: List<AbteilungsWarnung> = emptyList(),
// Zeitplan-Felder
val geplantesDatum: String? = null, // ISO-Format
val beginnZeit: String? = null, // "HH:mm"
val reitdauerMinuten: Int? = null,
val umbauMinuten: Int? = null,
val besichtigungMinuten: Int? = null,
val austragungsplatzId: String? = null,
)
data class AbteilungsWarnung(
@ -23,6 +30,7 @@ interface BewerbRepository {
suspend fun getById(id: Long): Result<Bewerb>
suspend fun create(model: Bewerb): Result<Bewerb>
suspend fun update(id: Long, model: Bewerb): Result<Bewerb>
suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb>
suspend fun delete(id: Long): Result<Unit>
suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit>
}

View File

@ -70,6 +70,7 @@ sealed interface BewerbIntent {
data object StartNetworkScan : BewerbIntent
data object StopNetworkScan : BewerbIntent
data object RefreshDiscoveredNodes : BewerbIntent
data class UpdateZeitplan(val id: Long, val beginnZeit: String?) : BewerbIntent
}
@ -160,6 +161,15 @@ class BewerbViewModel(
is BewerbIntent.StartNetworkScan -> startScan()
is BewerbIntent.StopNetworkScan -> stopScan()
is BewerbIntent.RefreshDiscoveredNodes -> refreshNodes()
is BewerbIntent.UpdateZeitplan -> updateZeitplan(intent.id, intent.beginnZeit)
}
}
private fun updateZeitplan(id: Long, beginn: String?) {
scope.launch {
repo.updateZeitplan(id, null, beginn, null).onSuccess {
load() // Neu laden um Konsistenz zu prüfen
}
}
}

View File

@ -72,6 +72,23 @@ class DefaultBewerbRepository(
}
}
override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> = runCatching {
val response = client.patch("${ApiRoutes.API_PREFIX}/bewerbe/$id/zeitplan") {
contentType(ContentType.Application.Json)
setBody(mapOf(
"geplantesDatum" to datum,
"beginnZeit" to beginn,
"austragungsplatzId" to platzId
))
}
when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id")
when {

View File

@ -12,6 +12,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.koin.compose.koinInject
import org.koin.core.parameter.parametersOf
/**
* Detailansicht eines Turniers gemäß Vision_03.
*
@ -61,6 +64,8 @@ fun TurnierDetailScreen(
"ERGEBNISLISTEN",
)
val bewerbViewModel: BewerbViewModel = koinInject { parametersOf(turnierId) }
Column(modifier = Modifier.fillMaxSize()) {
// Horizontale Tab-Bar (direkt unter der TopBar)
PrimaryScrollableTabRow(
@ -100,13 +105,11 @@ fun TurnierDetailScreen(
veranstalterLogoUrl = veranstalterLogoUrl,
)
1 -> OrganisationTabContent()
2 -> Box(modifier = Modifier.fillMaxSize()) {
Text("BEWERBE Tab (Anbindung in Arbeit)", modifier = Modifier.align(Alignment.Center))
}
2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId)
3 -> ArtikelTabContent()
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
5 -> NennungenTabContent(onAbrechnungClick = { selectedTab = 4 })
6 -> ZeitplanTabContent(turnierId = turnierId)
6 -> ZeitplanTabContent(turnierId = turnierId, viewModel = bewerbViewModel)
7 -> StartlistenTabContent()
8 -> ErgebnislistenTabContent()
}

View File

@ -38,9 +38,33 @@ private val MINUTE_HEIGHT = HOUR_HEIGHT / 60
*/
@Composable
fun ZeitplanTabContent(
turnierId: Long
turnierId: Long,
viewModel: BewerbViewModel
) {
var items by remember { mutableStateOf(sampleZeitplanItems()) }
val state by viewModel.state.collectAsState()
val items = state.filtered.map { bewerb ->
val startMin = if (bewerb.beginnZeit != null) {
val parts = bewerb.beginnZeit.split(":")
parts[0].toInt() * 60 + parts[1].toInt()
} else {
7 * 60 // Default 07:00 wenn nichts gesetzt
}
ZeitplanItemUi(
id = bewerb.id,
nummer = bewerb.tag.filter { it.isDigit() }.toIntOrNull() ?: 0,
name = bewerb.name,
startMinutes = startMin,
durationMinutes = bewerb.reitdauerMinuten ?: 60,
color = when (bewerb.sparte) {
"DRESSUR" -> Color(0xFF1E3A8A)
"SPRINGEN" -> Color(0xFF059669)
else -> ZeitplanBlue
},
hasConflict = bewerb.warnungen.isNotEmpty()
)
}
val scrollState = rememberScrollState()
Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) {
@ -63,13 +87,14 @@ fun ZeitplanTabContent(
ZeitplanGitter()
// Bewerbe / Blöcke
items.forEachIndexed { index, item ->
items.forEach { item ->
DraggableBewerbBox(
item = item,
onPositionChange = { newMinutes ->
items = items.toMutableList().apply {
this[index] = item.copy(startMinutes = newMinutes)
}
val h = newMinutes / 60
val m = newMinutes % 60
val timeStr = "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}"
viewModel.send(BewerbIntent.UpdateZeitplan(item.id, timeStr))
}
)
}

View File

@ -122,6 +122,7 @@ fun PreviewTurnierBewerbeTab() {
override suspend fun create(model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun update(id: Long, model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit)
}
val mockStartlistenRepo = object : StartlistenRepository {