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:
@@ -3,26 +3,27 @@
|
|||||||
## 📅 Session Info
|
## 📅 Session Info
|
||||||
- **Datum:** 2026-04-11
|
- **Datum:** 2026-04-11
|
||||||
- **Agenten:** 🏗️ Lead Architect, 👷 Backend Developer, 🎨 Frontend Expert, 🧹 Curator
|
- **Agenten:** 🏗️ Lead Architect, 👷 Backend Developer, 🎨 Frontend Expert, 🧹 Curator
|
||||||
- **Fokus:** Implementierung Zeitplan-Optimierung (Frontend Prototyp)
|
- **Fokus:** Integration Zeitplan-Optimierung & Datenanbindung
|
||||||
|
|
||||||
## 🏗️ Architektur-Entscheidungen
|
## 🏗️ Architektur-Entscheidungen
|
||||||
- **Komponente:** `TurnierZeitplanTab.kt` wurde als zentraler Ort für die visuelle Zeitplanung geschaffen.
|
- **Datenfluss:** `TurnierZeitplanTab.kt` wurde erfolgreich an das `BewerbViewModel` angebunden.
|
||||||
- **Layout:** 14-Stunden-Raster (07:00 - 21:00) mit feststehender Zeitachse und scrollbarem Gitter.
|
- **DI:** Das `BewerbViewModel` wird nun zentral im `TurnierDetailScreen` via Koin injiziert und an die Tabs (Bewerbe & Zeitplan) verteilt, um State-Konsistenz zu gewährleisten.
|
||||||
- **Interaktion:** Vertikales Drag & Drop mit 5-Minuten-Snapping zur präzisen Planung.
|
- **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/Integration
|
||||||
- **Backend:** Logik in `BewerbService` bereits vorhanden (vorherige Sessions).
|
- **API:** Unterstützung für `PATCH /bewerbe/{id}/zeitplan` im `DefaultBewerbRepository` implementiert.
|
||||||
- **Frontend-Store:** Aktuell noch Mock-Daten (`ZeitplanItemUi`), Anbindung an `BewerbViewModel` steht noch aus.
|
- **ViewModel:** Neuer Intent `BewerbIntent.UpdateZeitplan` zur persistierung von Zeitänderungen.
|
||||||
|
|
||||||
## 🎨 Frontend (Details)
|
## 🎨 Frontend (Details)
|
||||||
- **Modul:** `frontend:features:turnier-feature`
|
- **Mapping:** Automatisches Mapping von `Bewerb` (Domain) auf `ZeitplanItemUi` (Visual) inkl. dynamischer Farbwahl nach Sparte.
|
||||||
- **Datei:** `TurnierZeitplanTab.kt` (Neu)
|
- **Interaktion:** Drag & Drop Änderungen triggern nun echte API-Calls und laden den State neu.
|
||||||
- **Anpassung:** `TurnierDetailScreen.kt` um Tab "ZEITPLAN" erweitert.
|
- **UI:** Integration des "Bewerbe"-Tabs im `TurnierDetailScreen` vervollständigt (war vorher ein Platzhalter).
|
||||||
|
|
||||||
## 🧹 Curator Status & Cleanup
|
## 🧹 Curator Status & Cleanup
|
||||||
- ✅ Neue UI-Komponente erstellt und syntaktisch korrigiert.
|
- ✅ Datenmodelle und Mapper erweitert.
|
||||||
- ✅ Navigation im Turnier-Detail angepasst.
|
- ✅ Repository-Anbindung vervollständigt.
|
||||||
- 📂 Nächster Schritt: Mapping von `BewerbUiModel` auf `ZeitplanItemUi`.
|
- ✅ ViewModel-Integration im UI-Layer abgeschlossen.
|
||||||
|
- 📂 Nächster Schritt: Implementierung der automatischen Konfliktprüfung im Zeitplan (Rulebook-Validierung).
|
||||||
|
|
||||||
---
|
---
|
||||||
*Erstellt durch den Curator.*
|
*Erstellt durch den Curator.*
|
||||||
|
|||||||
+14
-2
@@ -18,7 +18,13 @@ fun BewerbDto.toDomain(): Bewerb = Bewerb(
|
|||||||
name = name,
|
name = name,
|
||||||
sparte = sparte,
|
sparte = sparte,
|
||||||
klasse = klasse,
|
klasse = klasse,
|
||||||
nennungen = nennungen
|
nennungen = nennungen,
|
||||||
|
geplantesDatum = geplantesDatum,
|
||||||
|
beginnZeit = beginnZeit,
|
||||||
|
reitdauerMinuten = reitdauerMinuten,
|
||||||
|
umbauMinuten = umbauMinuten,
|
||||||
|
besichtigungMinuten = besichtigungMinuten,
|
||||||
|
austragungsplatzId = austragungsplatzId,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Bewerb.toDto(): BewerbDto = BewerbDto(
|
fun Bewerb.toDto(): BewerbDto = BewerbDto(
|
||||||
@@ -29,7 +35,13 @@ fun Bewerb.toDto(): BewerbDto = BewerbDto(
|
|||||||
name = name,
|
name = name,
|
||||||
sparte = sparte,
|
sparte = sparte,
|
||||||
klasse = klasse,
|
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)
|
fun AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name)
|
||||||
|
|||||||
+6
@@ -18,6 +18,12 @@ data class BewerbDto(
|
|||||||
val sparte: String,
|
val sparte: String,
|
||||||
val klasse: String,
|
val klasse: String,
|
||||||
val nennungen: Int,
|
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
|
@Serializable
|
||||||
|
|||||||
+8
@@ -10,6 +10,13 @@ data class Bewerb(
|
|||||||
val klasse: String,
|
val klasse: String,
|
||||||
val nennungen: Int,
|
val nennungen: Int,
|
||||||
val warnungen: List<AbteilungsWarnung> = emptyList(),
|
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(
|
data class AbteilungsWarnung(
|
||||||
@@ -23,6 +30,7 @@ interface BewerbRepository {
|
|||||||
suspend fun getById(id: Long): Result<Bewerb>
|
suspend fun getById(id: Long): Result<Bewerb>
|
||||||
suspend fun create(model: Bewerb): Result<Bewerb>
|
suspend fun create(model: Bewerb): Result<Bewerb>
|
||||||
suspend fun update(id: Long, 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 delete(id: Long): Result<Unit>
|
||||||
suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit>
|
suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -70,6 +70,7 @@ sealed interface BewerbIntent {
|
|||||||
data object StartNetworkScan : BewerbIntent
|
data object StartNetworkScan : BewerbIntent
|
||||||
data object StopNetworkScan : BewerbIntent
|
data object StopNetworkScan : BewerbIntent
|
||||||
data object RefreshDiscoveredNodes : 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.StartNetworkScan -> startScan()
|
||||||
is BewerbIntent.StopNetworkScan -> stopScan()
|
is BewerbIntent.StopNetworkScan -> stopScan()
|
||||||
is BewerbIntent.RefreshDiscoveredNodes -> refreshNodes()
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
@@ -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 {
|
override suspend fun delete(id: Long): Result<Unit> = runCatching {
|
||||||
val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id")
|
val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id")
|
||||||
when {
|
when {
|
||||||
|
|||||||
+7
-4
@@ -12,6 +12,9 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detailansicht eines Turniers gemäß Vision_03.
|
* Detailansicht eines Turniers gemäß Vision_03.
|
||||||
*
|
*
|
||||||
@@ -61,6 +64,8 @@ fun TurnierDetailScreen(
|
|||||||
"ERGEBNISLISTEN",
|
"ERGEBNISLISTEN",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val bewerbViewModel: BewerbViewModel = koinInject { parametersOf(turnierId) }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Horizontale Tab-Bar (direkt unter der TopBar)
|
// Horizontale Tab-Bar (direkt unter der TopBar)
|
||||||
PrimaryScrollableTabRow(
|
PrimaryScrollableTabRow(
|
||||||
@@ -100,13 +105,11 @@ fun TurnierDetailScreen(
|
|||||||
veranstalterLogoUrl = veranstalterLogoUrl,
|
veranstalterLogoUrl = veranstalterLogoUrl,
|
||||||
)
|
)
|
||||||
1 -> OrganisationTabContent()
|
1 -> OrganisationTabContent()
|
||||||
2 -> Box(modifier = Modifier.fillMaxSize()) {
|
2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId)
|
||||||
Text("BEWERBE Tab (Anbindung in Arbeit)", modifier = Modifier.align(Alignment.Center))
|
|
||||||
}
|
|
||||||
3 -> ArtikelTabContent()
|
3 -> ArtikelTabContent()
|
||||||
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
|
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
|
||||||
5 -> NennungenTabContent(onAbrechnungClick = { selectedTab = 4 })
|
5 -> NennungenTabContent(onAbrechnungClick = { selectedTab = 4 })
|
||||||
6 -> ZeitplanTabContent(turnierId = turnierId)
|
6 -> ZeitplanTabContent(turnierId = turnierId, viewModel = bewerbViewModel)
|
||||||
7 -> StartlistenTabContent()
|
7 -> StartlistenTabContent()
|
||||||
8 -> ErgebnislistenTabContent()
|
8 -> ErgebnislistenTabContent()
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-6
@@ -38,9 +38,33 @@ private val MINUTE_HEIGHT = HOUR_HEIGHT / 60
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ZeitplanTabContent(
|
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()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) {
|
Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) {
|
||||||
@@ -63,13 +87,14 @@ fun ZeitplanTabContent(
|
|||||||
ZeitplanGitter()
|
ZeitplanGitter()
|
||||||
|
|
||||||
// Bewerbe / Blöcke
|
// Bewerbe / Blöcke
|
||||||
items.forEachIndexed { index, item ->
|
items.forEach { item ->
|
||||||
DraggableBewerbBox(
|
DraggableBewerbBox(
|
||||||
item = item,
|
item = item,
|
||||||
onPositionChange = { newMinutes ->
|
onPositionChange = { newMinutes ->
|
||||||
items = items.toMutableList().apply {
|
val h = newMinutes / 60
|
||||||
this[index] = item.copy(startMinutes = newMinutes)
|
val m = newMinutes % 60
|
||||||
}
|
val timeStr = "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}"
|
||||||
|
viewModel.send(BewerbIntent.UpdateZeitplan(item.id, timeStr))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -122,6 +122,7 @@ fun PreviewTurnierBewerbeTab() {
|
|||||||
override suspend fun create(model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
|
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 update(id: Long, model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
|
||||||
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
|
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)
|
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit)
|
||||||
}
|
}
|
||||||
val mockStartlistenRepo = object : StartlistenRepository {
|
val mockStartlistenRepo = object : StartlistenRepository {
|
||||||
|
|||||||
Reference in New Issue
Block a user