diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt index 0f85b00d..58fd5961 100644 --- a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt +++ b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt @@ -13,7 +13,8 @@ class GatewayConfig( @Value("\${ping.service.url:http://localhost:8082}") private val pingServiceUrl: String, @Value("\${masterdata.service.url:http://localhost:8086}") private val masterdataServiceUrl: String, @Value("\${events.service.url:http://localhost:8085}") private val eventsServiceUrl: String, - @Value("\${zns.import.service.url:http://localhost:8095}") private val znsImportServiceUrl: String + @Value("\${zns.import.service.url:http://localhost:8095}") private val znsImportServiceUrl: String, + @Value("\${results.service.url:http://localhost:8088}") private val resultsServiceUrl: String ) { @Bean @@ -42,6 +43,10 @@ class GatewayConfig( path("/api/v1/import/zns/**", "/api/v1/import/zns") uri(znsImportServiceUrl) } + route(id = "results-service") { + path("/api/v1/results/**") + uri(resultsServiceUrl) + } } } } diff --git a/backend/services/zns-import/Dockerfile b/backend/services/zns-import/Dockerfile new file mode 100644 index 00000000..d9c02e6d --- /dev/null +++ b/backend/services/zns-import/Dockerfile @@ -0,0 +1,78 @@ +# syntax=docker/dockerfile:1.7 + +# =================================================================== +# Dockerfile for ZNS Import Service +# =================================================================== + +ARG GRADLE_VERSION +ARG JAVA_VERSION +ARG BUILD_DATE +ARG VERSION + +# =================================================================== +# Build Stage +# =================================================================== +FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION}-alpine AS builder + +ARG SERVICE_PATH=zns-import/zns-import-service +ARG SERVICE_NAME=zns-import-service + +WORKDIR /workspace + +# Gradle optimizations +ENV GRADLE_OPTS="-Dorg.gradle.caching=true \ + -Dorg.gradle.daemon=false \ + -Dorg.gradle.parallel=true \ + -Dorg.gradle.configureondemand=true \ + -Xmx2g" + +# Copy build files +COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./ +COPY gradle/ gradle/ +RUN chmod +x gradlew + +COPY platform/ platform/ +COPY core/ core/ +COPY build.gradle.kts ./ + +# Copy service modules +COPY backend/services/zns-import/ backend/services/zns-import/ + +# Build service +RUN ./gradlew :zns-import-service:bootJar --no-daemon + +# Extract JAR layers +WORKDIR /builder +RUN cp /workspace/backend/services/zns-import/zns-import-service/build/libs/*.jar app.jar && \ + java -Djarmode=layertools -jar app.jar extract + +# =================================================================== +# Runtime Stage +# =================================================================== +FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime + +ARG APP_USER=znsuser +ARG APP_GROUP=znsgroup +ARG APP_UID=1009 +ARG APP_GID=1009 + +WORKDIR /app + +RUN apk add --no-cache curl jq tzdata + +RUN addgroup -g ${APP_GID} -S ${APP_GROUP} && \ + adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh + +COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./ +COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./ +COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./ +COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./ + +USER ${APP_USER} + +EXPOSE 8095 5009 + +ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom -Duser.timezone=Europe/Vienna" +ENV SERVER_PORT=8095 + +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"] diff --git a/dc-backend.yaml b/dc-backend.yaml index 2e836c04..a8b9cbf5 100644 --- a/dc-backend.yaml +++ b/dc-backend.yaml @@ -62,6 +62,7 @@ services: MASTERDATA_SERVICE_URL: "http://masterdata-service:8086" EVENTS_SERVICE_URL: "http://events-service:8085" ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095" + RESULTS_SERVICE_URL: "http://results-service:8088" depends_on: postgres: @@ -390,6 +391,81 @@ services: volumes: - ./config/app/base-application.yaml:/workspace/config/application.yml:Z + # --- MICROSERVICE: Results Service --- + results-service: + image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/results-service:${DOCKER_TAG:-latest}" + build: + context: . + dockerfile: backend/services/results/results-service/Dockerfile + args: + GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.3.1}" + JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}" + VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}" + BUILD_DATE: "${DOCKER_BUILD_DATE}" + labels: + - "org.opencontainers.image.created=${DOCKER_BUILD_DATE}" + container_name: "${PROJECT_NAME:-meldestelle}-results-service" + restart: unless-stopped + ports: + - "${RESULTS_PORT:-8088:8088}" + - "${RESULTS_DEBUG_PORT:-5010:5010}" + environment: + SPRING_PROFILES_ACTIVE: "${RESULTS_SPRING_PROFILES_ACTIVE:-docker}" + DEBUG: "${RESULTS_DEBUG:-true}" + SERVER_PORT: "${RESULTS_SERVER_PORT:-8088}" + + # --- KEYCLOAK --- + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://localhost:8180/realms/meldestelle}" + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}" + + # --- CONSUL --- + SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}" + SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}" + SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${RESULTS_SERVICE_NAME:-results-service}" + SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${RESULTS_CONSUL_PREFER_IP:-true}" + + # - DATENBANK VERBINDUNG - + SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}" + SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}" + SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}" + + # --- VALKEY (formerly Redis) --- + SPRING_DATA_VALKEY_HOST: "${VALKEY_SERVER_HOSTNAME:-valkey}" + SPRING_DATA_VALKEY_PORT: "${VALKEY_SERVER_PORT:-6379}" + SPRING_DATA_VALKEY_PASSWORD: "${VALKEY_PASSWORD:-}" + SPRING_DATA_VALKEY_CONNECT_TIMEOUT: "${VALKEY_SERVER_CONNECT_TIMEOUT:-5s}" + + # --- ZIPKIN --- + MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}" + MANAGEMENT_TRACING_SAMPLING_PROBABILITY: "${ZIPKIN_SAMPLING_PROBABILITY:-1.0}" + + depends_on: + postgres: + condition: "service_healthy" + keycloak: + condition: "service_healthy" + consul: + condition: "service_healthy" + valkey: + condition: "service_healthy" + zipkin: + condition: "service_started" + + healthcheck: + test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8088/actuator/health/readiness" ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 40s + + networks: + meldestelle-network: + aliases: + - "results-service" + profiles: [ "backend", "all" ] + volumes: + - ./config/app/base-application.yaml:/workspace/config/application.yml:Z + networks: meldestelle-network: driver: bridge diff --git a/docs/04_Agents/Logs/2026-04-12_Ergebniserfassung_Curator_Log.md b/docs/04_Agents/Logs/2026-04-12_Ergebniserfassung_Curator_Log.md new file mode 100644 index 00000000..a1b993bb --- /dev/null +++ b/docs/04_Agents/Logs/2026-04-12_Ergebniserfassung_Curator_Log.md @@ -0,0 +1,32 @@ +# 🧹 [Curator] Log - 2026-04-12 (Phase 11: Ergebniserfassung) + +## Status +- **Phase 10.3 (Echter Datenverkehr):** ✅ Completed +- **Phase 11 (Ergebniserfassung):** 🏗️ In Progress (UI & Repository ready) + +## Heute erledigt +- **Infrastruktur:** + - `results-service` in `dc-backend.yaml` und `GatewayConfig.kt` integriert. + - Dockerfile für `zns-import-service` korrigiert/erstellt. +- **Frontend Domain:** + - `ErgebnisRepository` und `Ergebnis` Modell definiert. + - `StartlistenZeile` um `nennungId` erweitert. +- **Frontend Data:** + - `DefaultErgebnisRepository` (Ktor) implementiert. + - Koin-DI für Ergebnisse konfiguriert und `TurnierFeatureModule.kt` korrigiert (BewerbViewModel DI fix). +- **Frontend UI:** + - `ErgebnisEditDialog` zur schnellen Ergebniserfassung erstellt. + - `TurnierStartlistenTab` funktionalisiert: Klick auf Starter öffnet Erfassungs-Dialog. + - `TurnierErgebnislistenTab` dynamisiert: Zeigt nun reale Ergebnisse aus dem Repository an. + - `BewerbViewModel` um Ergebnis-Management (Load/Save) erweitert. + - **Fix:** Mock-Implementierungen in `ScreenPreviews.kt` für das `BewerbViewModel` aktualisiert (fehlendes `ErgebnisRepository`). + +## Nächste Schritte +- Platzierungs-Berechnung im Backend/Frontend finalisieren. +- Druck-Funktion für Ergebnislisten (PDF-Export). +- Offline-Synchronisation für erfasste Ergebnisse prüfen. + +## Verifikation +- Kompilierung des Desktop-Frontends erfolgreich. +- DI-Konfiguration für neue Repositories geprüft. +- Gateway-Routen für `results-service` syntaktisch korrekt. diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt index 697f3dab..40fb145c 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt @@ -31,4 +31,9 @@ object ApiRoutes { object Events { const val ROOT = "/api/v1/events" } + + object Results { + const val ROOT = "/api/v1/results" + fun bewerb(bewerbId: String) = "$ROOT/bewerb/$bewerbId" + } } diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/ErgebnisRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/ErgebnisRepository.kt new file mode 100644 index 00000000..3550e7b3 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/ErgebnisRepository.kt @@ -0,0 +1,26 @@ +package at.mocode.turnier.feature.domain + +import kotlinx.serialization.Serializable + +@Serializable +data class Ergebnis( + val id: String? = null, + val nennungId: String, + val bewerbId: String, + val wertnote: Double? = null, + val zeit: Double? = null, + val fehler: Double? = null, + val status: ErgebnisStatus = ErgebnisStatus.OK, + val platzierung: Int? = null +) + +@Serializable +enum class ErgebnisStatus { + OK, AUSGESCHIEDEN, VERZICHTET, DISQUALIFIZIERT, NICHT_GESTARTET +} + +interface ErgebnisRepository { + suspend fun getForBewerb(bewerbId: String): Result> + suspend fun save(ergebnis: Ergebnis): Result + suspend fun calculatePlatzierung(bewerbId: String): 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 06c29400..2ecdefa4 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 @@ -28,6 +28,7 @@ data class StartlistenZeile( val reiter: String, val pferd: String, val wunsch: String, + val nennungId: String = "" ) data class BewerbState( @@ -49,8 +50,11 @@ data class BewerbState( val isAuditLoading: Boolean = false, val exportContent: String? = null, val showExportDialog: Boolean = false, + val ergebnisse: List = emptyList(), // Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional) val dialogState: BewerbAnlegenState = BewerbAnlegenState(), + val editingErgebnis: at.mocode.turnier.feature.domain.Ergebnis? = null, + val selectedZeile: StartlistenZeile? = null ) sealed interface BewerbIntent { @@ -79,12 +83,17 @@ sealed interface BewerbIntent { data class LoadAuditLog(val bewerbId: Long) : BewerbIntent data object ExportZnsBSatz : BewerbIntent data object CloseExportDialog : BewerbIntent + data object LoadErgebnisse : BewerbIntent + data class OpenErgebnisEdit(val zeile: StartlistenZeile) : BewerbIntent + data object CloseErgebnisEdit : BewerbIntent + data class SaveErgebnis(val ergebnis: at.mocode.turnier.feature.domain.Ergebnis) : BewerbIntent } class BewerbViewModel( private val repo: BewerbRepository, private val startlistenRepo: StartlistenRepository, + private val ergebnisRepo: at.mocode.turnier.feature.domain.ErgebnisRepository, private val syncManager: SyncManager? = null, private val turnierId: Long, ) { @@ -134,7 +143,12 @@ class BewerbViewModel( when (intent) { is BewerbIntent.Load, is BewerbIntent.Refresh -> load() is BewerbIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() } - is BewerbIntent.Select -> reduce { it.copy(selectedId = intent.id) } + is BewerbIntent.Select -> { + reduce { it.copy(selectedId = intent.id) } + if (intent.id != null) { + loadErgebnisse() + } + } is BewerbIntent.ClearError -> reduce { it.copy(errorMessage = null) } is BewerbIntent.OpenDialog -> { @@ -173,6 +187,37 @@ class BewerbViewModel( is BewerbIntent.LoadAuditLog -> loadAuditLog(intent.bewerbId) is BewerbIntent.ExportZnsBSatz -> exportZnsBSatz() is BewerbIntent.CloseExportDialog -> reduce { it.copy(showExportDialog = false, exportContent = null) } + is BewerbIntent.LoadErgebnisse -> loadErgebnisse() + is BewerbIntent.OpenErgebnisEdit -> { + val bewerbId = state.value.selectedId?.toString() ?: "" + reduce { + it.copy( + selectedZeile = intent.zeile, + editingErgebnis = at.mocode.turnier.feature.domain.Ergebnis( + nennungId = intent.zeile.nennungId, + bewerbId = bewerbId + ) + ) + } + } + is BewerbIntent.CloseErgebnisEdit -> reduce { it.copy(editingErgebnis = null, selectedZeile = null) } + is BewerbIntent.SaveErgebnis -> { + scope.launch { + ergebnisRepo.save(intent.ergebnis).onSuccess { + reduce { it.copy(editingErgebnis = null, selectedZeile = null) } + loadErgebnisse() + } + } + } + } + } + + private fun loadErgebnisse() { + val bewerbId = state.value.selectedId ?: return + scope.launch { + ergebnisRepo.getForBewerb(bewerbId.toString()).onSuccess { list -> + reduce { it.copy(ergebnisse = list) } + } } } diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultErgebnisRepository.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultErgebnisRepository.kt new file mode 100644 index 00000000..d88c55aa --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultErgebnisRepository.kt @@ -0,0 +1,36 @@ +package at.mocode.turnier.feature.data.remote + +import at.mocode.frontend.core.network.ApiRoutes +import at.mocode.turnier.feature.domain.Ergebnis +import at.mocode.turnier.feature.domain.ErgebnisRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class DefaultErgebnisRepository( + private val client: HttpClient +) : ErgebnisRepository { + + override suspend fun getForBewerb(bewerbId: String): Result> = runCatching { + client.get(ApiRoutes.Results.bewerb(bewerbId)).body() + } + + override suspend fun save(ergebnis: Ergebnis): Result = runCatching { + if (ergebnis.id == null) { + client.post(ApiRoutes.Results.ROOT) { + contentType(ContentType.Application.Json) + setBody(ergebnis) + }.body() + } else { + client.put("${ApiRoutes.Results.ROOT}/${ergebnis.id}") { + contentType(ContentType.Application.Json) + setBody(ergebnis) + }.body() + } + } + + override suspend fun calculatePlatzierung(bewerbId: String): Result> = runCatching { + client.post("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/calculate").body() + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt index 321a3e35..9257f88d 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt @@ -18,6 +18,7 @@ val turnierFeatureModule = module { single { DefaultStartlistenRepository(client = get(qualifier = named("apiClient"))) } single { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) } single { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) } + single { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) } // ViewModels factory { TurnierViewModel(repo = get()) } @@ -26,6 +27,7 @@ val turnierFeatureModule = module { BewerbViewModel( repo = get(), startlistenRepo = get(), + ergebnisRepo = get(), syncManager = getOrNull(), turnierId = turnierId ) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/MasterdataEditDialogs.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/MasterdataEditDialogs.kt index 0d57cf41..b5b5c2b8 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/MasterdataEditDialogs.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/MasterdataEditDialogs.kt @@ -1,12 +1,81 @@ package at.mocode.turnier.feature.presentation -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import at.mocode.turnier.feature.domain.Pferd import at.mocode.turnier.feature.domain.Reiter +import at.mocode.turnier.feature.domain.Ergebnis +import at.mocode.turnier.feature.domain.ErgebnisStatus + +@Composable +fun ErgebnisEditDialog( + ergebnis: Ergebnis, + reiterName: String, + pferdName: String, + onDismiss: () -> Unit, + onSave: (Ergebnis) -> Unit +) { + var wertnote by remember { mutableStateOf(ergebnis.wertnote?.toString() ?: "") } + var zeit by remember { mutableStateOf(ergebnis.zeit?.toString() ?: "") } + var fehler by remember { mutableStateOf(ergebnis.fehler?.toString() ?: "") } + var status by remember { mutableStateOf(ergebnis.status) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Ergebnis erfassen: $reiterName mit $pferdName") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = wertnote, + onValueChange = { wertnote = it }, + label = { Text("Wertnote") } + ) + OutlinedTextField( + value = zeit, + onValueChange = { zeit = it }, + label = { Text("Zeit") } + ) + OutlinedTextField( + value = fehler, + onValueChange = { fehler = it }, + label = { Text("Fehler") } + ) + + Text("Status") + Column { + ErgebnisStatus.entries.forEach { s -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = status == s, onClick = { status = s }) + Text(s.name, modifier = Modifier.clickable { status = s }) + } + } + } + } + }, + confirmButton = { + Button(onClick = { + onSave(ergebnis.copy( + wertnote = wertnote.toDoubleOrNull(), + zeit = zeit.toDoubleOrNull(), + fehler = fehler.toDoubleOrNull(), + status = status + )) + }) { + Text("Speichern") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} @Composable fun ReiterEditDialog( diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt index d2fb9c16..5c1a4ecc 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import at.mocode.turnier.feature.domain.Bewerb +import at.mocode.turnier.feature.domain.Ergebnis +import at.mocode.turnier.feature.presentation.StartlistenZeile import org.koin.compose.koinInject private val ElBlue = Color(0xFF1E3A8A) @@ -35,7 +37,9 @@ fun ErgebnislistenTabContent( ErgebnislistenBewerbsTabs( bewerbe = state.list, selectedId = state.selectedId, - onSelect = { viewModel.send(BewerbIntent.Select(it)) } + onSelect = { viewModel.send(BewerbIntent.Select(it)) }, + ergebnisse = state.ergebnisse, + startliste = state.currentStartliste ) } @@ -50,7 +54,9 @@ fun ErgebnislistenTabContent( private fun ErgebnislistenBewerbsTabs( bewerbe: List, selectedId: Long?, - onSelect: (Long?) -> Unit + onSelect: (Long?) -> Unit, + ergebnisse: List, + startliste: List ) { val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0) @@ -121,25 +127,47 @@ private fun ErgebnislistenBewerbsTabs( } HorizontalDivider() - // Leere Liste - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Keine Ergebnisse vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280)) - Spacer(Modifier.height(8.dp)) - Text("Ergebnisse werden nach dem Turnier eingetragen.", fontSize = 12.sp, color = Color(0xFF9CA3AF)) - Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = {}) { - Text("Ergebnisse importieren", fontSize = 13.sp) - } - Button( - onClick = {}, - colors = ButtonDefaults.buttonColors(containerColor = ElBlue), - ) { - Text("Ergebnisse eingeben", fontSize = 13.sp) + if (ergebnisse.isEmpty()) { + // Leere Liste + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Keine Ergebnisse vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280)) + Spacer(Modifier.height(8.dp)) + Text("Ergebnisse werden nach dem Turnier eingetragen.", fontSize = 12.sp, color = Color(0xFF9CA3AF)) + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = {}) { + Text("Ergebnisse importieren", fontSize = 13.sp) + } + Button( + onClick = {}, + colors = ButtonDefaults.buttonColors(containerColor = ElBlue), + ) { + Text("Ergebnisse eingeben", fontSize = 13.sp) + } } } } + } else { + androidx.compose.foundation.lazy.LazyColumn(modifier = Modifier.fillMaxSize()) { + items(ergebnisse.size) { index -> + val erg = ergebnisse[index] + val zeile = startliste.find { it.nennungId == erg.nennungId } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(erg.platzierung?.let { "$it." } ?: "-", fontSize = 12.sp, modifier = Modifier.width(50.dp), fontWeight = FontWeight.Bold, color = ElBlue) + Text(zeile?.nr?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(65.dp)) + Text(zeile?.pferd ?: "Unbekannt", fontSize = 12.sp, modifier = Modifier.weight(1f)) + Text(zeile?.reiter ?: "Unbekannt", fontSize = 12.sp, modifier = Modifier.weight(1f)) + Text(erg.fehler?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(60.dp)) + Text(erg.zeit?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(70.dp)) + Text(erg.wertnote?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(70.dp)) + } + HorizontalDivider(color = Color(0xFFE5E7EB)) + } + } } } diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStartlistenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStartlistenTab.kt index 6a3665b5..3475c0d2 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStartlistenTab.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStartlistenTab.kt @@ -1,6 +1,7 @@ package at.mocode.turnier.feature.presentation import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -38,7 +39,8 @@ fun StartlistenTabContent( selectedId = state.selectedId, onSelect = { viewModel.send(BewerbIntent.Select(it)) }, currentStartliste = state.currentStartliste, - onGenerate = { viewModel.generateStartliste() } + onGenerate = { viewModel.generateStartliste() }, + onRowClick = { viewModel.send(BewerbIntent.OpenErgebnisEdit(it)) } ) } @@ -47,6 +49,20 @@ fun StartlistenTabContent( // ── Rechte Spalte: Sortierung & Zeit ───────────────────────────────── StartlistenSortierPanel(modifier = Modifier.width(280.dp).fillMaxHeight()) } + + // Ergebnis-Dialog + state.editingErgebnis?.let { ergebnis -> + val zeile = state.selectedZeile + if (zeile != null) { + ErgebnisEditDialog( + ergebnis = ergebnis, + reiterName = zeile.reiter, + pferdName = zeile.pferd, + onDismiss = { viewModel.send(BewerbIntent.CloseErgebnisEdit) }, + onSave = { viewModel.send(BewerbIntent.SaveErgebnis(it)) } + ) + } + } } @Composable @@ -55,7 +71,8 @@ private fun StartlistenBewerbsTabs( selectedId: Long?, onSelect: (Long?) -> Unit, currentStartliste: List, - onGenerate: () -> Unit + onGenerate: () -> Unit, + onRowClick: (StartlistenZeile) -> Unit ) { val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0) @@ -139,7 +156,10 @@ private fun StartlistenBewerbsTabs( items(currentStartliste.size) { index -> val zeile = currentStartliste[index] Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { onRowClick(zeile) } + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Text(zeile.nr.toString(), fontSize = 12.sp, modifier = Modifier.width(70.dp)) diff --git a/frontend/features/verein-feature/src/jvmMain/kotlin/at/mocode/frontend/features/verein/di/VereinFeatureModule.kt b/frontend/features/verein-feature/src/jvmMain/kotlin/at/mocode/frontend/features/verein/di/VereinFeatureModule.kt index 97b820d9..21bdbb82 100644 --- a/frontend/features/verein-feature/src/jvmMain/kotlin/at/mocode/frontend/features/verein/di/VereinFeatureModule.kt +++ b/frontend/features/verein-feature/src/jvmMain/kotlin/at/mocode/frontend/features/verein/di/VereinFeatureModule.kt @@ -3,9 +3,7 @@ package at.mocode.frontend.features.verein.di import at.mocode.frontend.features.verein.data.KtorVereinRepository import at.mocode.frontend.features.verein.domain.VereinRepository import at.mocode.frontend.features.verein.presentation.VereinViewModel -import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf -import org.koin.dsl.bind import org.koin.dsl.module val vereinFeatureModule = module { 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 02af1445..1f6fd875 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 @@ -149,9 +149,15 @@ fun PreviewTurnierBewerbeTab() { override suspend fun generate(bewerbId: Long): Result> = Result.success(emptyList()) override suspend fun getByBewerb(bewerbId: Long): Result> = Result.success(emptyList()) } + val mockErgebnisRepo = object : at.mocode.turnier.feature.domain.ErgebnisRepository { + override suspend fun getForBewerb(bewerbId: String): Result> = Result.success(emptyList()) + override suspend fun save(ergebnis: at.mocode.turnier.feature.domain.Ergebnis): Result = Result.success(ergebnis) + override suspend fun calculatePlatzierung(bewerbId: String): Result> = Result.success(emptyList()) + } val vm = BewerbViewModel( repo = mockRepo, startlistenRepo = mockStartlistenRepo, + ergebnisRepo = mockErgebnisRepo, syncManager = null, turnierId = 1L )