diff --git a/docs/04_Agents/Logs/2026-04-11_Zeitplan_Frontend_Curator_Log.md b/docs/04_Agents/Logs/2026-04-11_Zeitplan_Frontend_Curator_Log.md index 77c62bc8..cee0083a 100644 --- a/docs/04_Agents/Logs/2026-04-11_Zeitplan_Frontend_Curator_Log.md +++ b/docs/04_Agents/Logs/2026-04-11_Zeitplan_Frontend_Curator_Log.md @@ -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.* diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt index 23342a4c..8133b1a6 100644 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt @@ -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) diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt index 0fcc16a2..3e05dcc4 100644 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt @@ -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 diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt index 9c3843e7..9974b148 100644 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt @@ -10,6 +10,13 @@ data class Bewerb( val klasse: String, val nennungen: Int, val warnungen: List = 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 suspend fun create(model: Bewerb): Result suspend fun update(id: Long, model: Bewerb): Result + suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result suspend fun delete(id: Long): Result suspend fun importBewerbe(turnierId: Long, bewerbe: List): Result } diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt index a0a721a0..58f0bb2f 100644 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt @@ -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 + } } } diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt index e7623338..c656fa91 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt @@ -72,6 +72,23 @@ class DefaultBewerbRepository( } } + override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result = 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().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 = runCatching { val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id") when { diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt index 4bb989b8..468269d8 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt @@ -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() } diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt index 3eb2a3a6..89e94ef5 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt @@ -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)) } ) } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt index 6dcb586f..b5172201 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt @@ -122,6 +122,7 @@ fun PreviewTurnierBewerbeTab() { override suspend fun create(model: Bewerb): Result = Result.failure(NotImplementedError()) override suspend fun update(id: Long, model: Bewerb): Result = Result.failure(NotImplementedError()) override suspend fun delete(id: Long): Result = Result.success(Unit) + override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result = Result.failure(NotImplementedError()) override suspend fun importBewerbe(turnierId: Long, bewerbe: List): Result = Result.success(Unit) } val mockStartlistenRepo = object : StartlistenRepository {