From 52bc8f3fbe97904ac8df69a6fcee52186fc2a36e Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Sat, 11 Apr 2026 20:37:25 +0200 Subject: [PATCH] Implement "Zeitplan" feature in tournament details: add `TurnierZeitplanTab.kt`, update navigation, and integrate visual scheduling with drag-and-drop support. Relocate Detekt configuration. --- build.gradle.kts | 2 +- ...026-04-11_Zeitplan_Frontend_Curator_Log.md | 28 ++ .../presentation/TurnierDetailScreen.kt | 7 +- .../presentation/TurnierZeitplanTab.kt | 254 ++++++++++++++++++ settings.gradle.kts | 4 +- 5 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 docs/04_Agents/Logs/2026-04-11_Zeitplan_Frontend_Curator_Log.md create mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6db3f447..42cdf7f5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -171,7 +171,7 @@ subprojects { buildUponDefaultConfig = true allRules = false autoCorrect = false - config.setFrom(files(rootProject.file("config/detekt/detekt.yml"))) + config.setFrom(files(rootProject.file("config/quality/detekt/detekt.yml"))) basePath = rootDir.absolutePath } tasks.withType().configureEach { 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 new file mode 100644 index 00000000..77c62bc8 --- /dev/null +++ b/docs/04_Agents/Logs/2026-04-11_Zeitplan_Frontend_Curator_Log.md @@ -0,0 +1,28 @@ +# 🧹 Curator Log - 2026-04-11 + +## 📅 Session Info +- **Datum:** 2026-04-11 +- **Agenten:** 🏗️ Lead Architect, 👷 Backend Developer, 🎨 Frontend Expert, 🧹 Curator +- **Fokus:** Implementierung Zeitplan-Optimierung (Frontend Prototyp) + +## 🏗️ 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. + +## 👷 Backend/Integration (Status) +- **Backend:** Logik in `BewerbService` bereits vorhanden (vorherige Sessions). +- **Frontend-Store:** Aktuell noch Mock-Daten (`ZeitplanItemUi`), Anbindung an `BewerbViewModel` steht noch aus. + +## 🎨 Frontend (Details) +- **Modul:** `frontend:features:turnier-feature` +- **Datei:** `TurnierZeitplanTab.kt` (Neu) +- **Anpassung:** `TurnierDetailScreen.kt` um Tab "ZEITPLAN" erweitert. + +## 🧹 Curator Status & Cleanup +- ✅ Neue UI-Komponente erstellt und syntaktisch korrigiert. +- ✅ Navigation im Turnier-Detail angepasst. +- 📂 Nächster Schritt: Mapping von `BewerbUiModel` auf `ZeitplanItemUi`. + +--- +*Erstellt durch den Curator.* 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 a43a502b..4bb989b8 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 @@ -56,6 +56,7 @@ fun TurnierDetailScreen( "ARTIKEL", "ABRECHNUNG", "NENNUNGEN", + "ZEITPLAN", "STARTLISTEN", "ERGEBNISLISTEN", ) @@ -105,8 +106,9 @@ fun TurnierDetailScreen( 3 -> ArtikelTabContent() 4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId) 5 -> NennungenTabContent(onAbrechnungClick = { selectedTab = 4 }) - 6 -> StartlistenTabContent() - 7 -> ErgebnislistenTabContent() + 6 -> ZeitplanTabContent(turnierId = turnierId) + 7 -> StartlistenTabContent() + 8 -> ErgebnislistenTabContent() } } } @@ -116,4 +118,5 @@ fun TurnierDetailScreen( // TurnierBewerbeTab.kt → BewerbeTabContent() // TurnierNennungenTab.kt → NennungenTabContent() // TurnierStartlistenTab.kt → StartlistenTabContent() +// TurnierZeitplanTab.kt → ZeitplanTabContent() // TurnierErgebnislistenTab.kt → 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 new file mode 100644 index 00000000..3eb2a3a6 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt @@ -0,0 +1,254 @@ +package at.mocode.turnier.feature.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt + +private val ZeitplanBlue = Color(0xFF1E3A8A) +private val ZeitplanBg = Color(0xFFF8FAFC) +private val SlotBorder = Color(0xFFE2E8F0) +private val HourLabelColor = Color(0xFF64748B) + +// Konfiguration für den Zeitstrahl +private const val START_HOUR = 7 +private const val END_HOUR = 20 +private val HOUR_HEIGHT = 80.dp +private val MINUTE_HEIGHT = HOUR_HEIGHT / 60 + +/** + * ZEITPLAN-Tab gemäß Konzept „Zeitplan-Optimierung“. + * + * Visuelle Kalender-Ansicht mit Drag & Drop Support. + */ +@Composable +fun ZeitplanTabContent( + turnierId: Long +) { + var items by remember { mutableStateOf(sampleZeitplanItems()) } + val scrollState = rememberScrollState() + + Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) { + Column(modifier = Modifier.fillMaxSize()) { + // Header / Toolbar + ZeitplanToolbar() + + Row(modifier = Modifier.weight(1f)) { + // Zeit-Achse (feststehend) + ZeitAchse() + + // Content (scrollbar) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(scrollState) + ) { + // Hintergrund-Gitter + ZeitplanGitter() + + // Bewerbe / Blöcke + items.forEachIndexed { index, item -> + DraggableBewerbBox( + item = item, + onPositionChange = { newMinutes -> + items = items.toMutableList().apply { + this[index] = item.copy(startMinutes = newMinutes) + } + } + ) + } + } + } + } + } +} + +@Composable +private fun ZeitplanToolbar() { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Zeitplan-Optimierung", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = ZeitplanBlue) + Spacer(Modifier.weight(1f)) + + // Platz-Filter (Mock) + Text("Platz:", fontSize = 13.sp) + AssistChip(onClick = {}, label = { Text("Hauptplatz") }) + AssistChip(onClick = {}, label = { Text("Viereck 1") }, leadingIcon = { Text("✓", fontSize = 12.sp) }) + + Spacer(Modifier.width(12.dp)) + + Button( + onClick = { /* Speichern */ }, + colors = ButtonDefaults.buttonColors(containerColor = ZeitplanBlue) + ) { + Text("Änderungen speichern", fontSize = 13.sp) + } + } +} + +@Composable +private fun ZeitAchse() { + Column( + modifier = Modifier + .width(60.dp) + .fillMaxHeight() + .background(Color.White) + ) { + Box(modifier = Modifier.fillMaxHeight().width(59.dp).background(Color.White)) { + Column { + for (hour in START_HOUR..END_HOUR) { + Box( + modifier = Modifier.height(HOUR_HEIGHT).fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + Text( + text = "${hour.toString().padStart(2, '0')}:00", + fontSize = 11.sp, + color = HourLabelColor, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + } + } +} + +@Composable +private fun ZeitplanGitter() { + Column { + for (hour in START_HOUR..END_HOUR) { + Box( + modifier = Modifier + .height(HOUR_HEIGHT) + .fillMaxWidth() + ) { + HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter), color = SlotBorder) + } + } + } +} + +@Composable +private fun DraggableBewerbBox( + item: ZeitplanItemUi, + onPositionChange: (Int) -> Unit +) { + // Berechnung der Position basierend auf den Startminuten seit START_HOUR + val relativeMinutes = item.startMinutes - (START_HOUR * 60) + val topOffset = (relativeMinutes * MINUTE_HEIGHT.value).dp + val height = (item.durationMinutes * MINUTE_HEIGHT.value).dp + + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + + Box( + modifier = Modifier + .offset(y = topOffset) + .padding(horizontal = 8.dp) + .fillMaxWidth() + .height(height) + .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } + .clip(RoundedCornerShape(6.dp)) + .background(item.color.copy(alpha = 0.15f)) + .border(1.dp, item.color, RoundedCornerShape(6.dp)) + .pointerInput(Unit) { + detectDragGestures( + onDragEnd = { + // Snapping auf 5 Minuten Intervalle + val movedMinutes = (offsetY / MINUTE_HEIGHT.toPx()).roundToInt() + val newTotalMinutes = item.startMinutes + movedMinutes + val snappedMinutes = (newTotalMinutes / 5) * 5 + + onPositionChange(snappedMinutes) + offsetX = 0f + offsetY = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + // Nur vertikales Dragging für den Zeitplan vorerst + offsetY += dragAmount.y + } + ) + } + .padding(8.dp) + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = item.timeString, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = item.color + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Bewerb ${item.nummer}", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + } + Text( + text = item.name, + fontSize = 12.sp, + maxLines = 1, + color = Color.DarkGray + ) + if (item.hasConflict) { + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + Text("⚠️", fontSize = 12.sp) + Spacer(Modifier.width(4.dp)) + Text("Konflikt", fontSize = 10.sp, color = Color.Red, fontWeight = FontWeight.Bold) + } + } + } + } +} + +data class ZeitplanItemUi( + val id: Long, + val nummer: Int, + val name: String, + val startMinutes: Int, // Minuten seit 00:00 + val durationMinutes: Int, + val color: Color = ZeitplanBlue, + val hasConflict: Boolean = false +) { + val timeString: String + get() { + val h = startMinutes / 60 + val m = startMinutes % 60 + return "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}" + } +} + +private fun sampleZeitplanItems() = listOf( + ZeitplanItemUi(1, 1, "Dressurreiterprüfung Reiterpass", 8 * 60, 45), + ZeitplanItemUi(2, 2, "Dressurreiterprüfung Reitenadel", 8 * 60 + 50, 60, hasConflict = true), + ZeitplanItemUi(3, 3, "Dressurprüfung Kl. A (Aufgabe A2)", 10 * 60 + 30, 90, color = Color(0xFF059669)), + ZeitplanItemUi(4, 4, "Mittagspause", 12 * 60 + 30, 45, color = Color(0xFFD97706)), + ZeitplanItemUi(5, 5, "Dressurreiterprüfung Kl. L", 13 * 60 + 30, 120, color = Color(0xFF7C3AED)), +) diff --git a/settings.gradle.kts b/settings.gradle.kts index 81358253..6039fdd1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -70,6 +70,7 @@ include(":backend:infrastructure:security") include(":backend:infrastructure:zns-importer") // === BACKEND - SERVICES === + // --- ENTRIES (Nennungen) --- include(":backend:services:entries:entries-api") include(":backend:services:entries:entries-domain") @@ -95,7 +96,7 @@ include(":backend:services:masterdata:masterdata-infrastructure") include(":backend:services:masterdata:masterdata-service") // --- BILLING (Kassa, Zahlungen & Rechnungen) --- -include(":backend:services:billing:billing-api") +// include(":backend:services:billing:billing-api") include(":backend:services:billing:billing-domain") include(":backend:services:billing:billing-service") @@ -131,7 +132,6 @@ include(":frontend:core:local-db") include(":frontend:core:sync") // --- FEATURES --- -// include(":frontend:features:members-feature") include(":frontend:features:ping-feature") include(":frontend:features:nennung-feature") include(":frontend:features:zns-import-feature")