Add results-service microservice with API gateway integration, implement Ergebnis repository and edit dialog, update BewerbViewModel for Ergebniserfassung, and enhance Turnier UI with result management features.
This commit is contained in:
+6
-1
@@ -13,7 +13,8 @@ class GatewayConfig(
|
|||||||
@Value("\${ping.service.url:http://localhost:8082}") private val pingServiceUrl: String,
|
@Value("\${ping.service.url:http://localhost:8082}") private val pingServiceUrl: String,
|
||||||
@Value("\${masterdata.service.url:http://localhost:8086}") private val masterdataServiceUrl: String,
|
@Value("\${masterdata.service.url:http://localhost:8086}") private val masterdataServiceUrl: String,
|
||||||
@Value("\${events.service.url:http://localhost:8085}") private val eventsServiceUrl: 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
|
@Bean
|
||||||
@@ -42,6 +43,10 @@ class GatewayConfig(
|
|||||||
path("/api/v1/import/zns/**", "/api/v1/import/zns")
|
path("/api/v1/import/zns/**", "/api/v1/import/zns")
|
||||||
uri(znsImportServiceUrl)
|
uri(znsImportServiceUrl)
|
||||||
}
|
}
|
||||||
|
route(id = "results-service") {
|
||||||
|
path("/api/v1/results/**")
|
||||||
|
uri(resultsServiceUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -62,6 +62,7 @@ services:
|
|||||||
MASTERDATA_SERVICE_URL: "http://masterdata-service:8086"
|
MASTERDATA_SERVICE_URL: "http://masterdata-service:8086"
|
||||||
EVENTS_SERVICE_URL: "http://events-service:8085"
|
EVENTS_SERVICE_URL: "http://events-service:8085"
|
||||||
ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095"
|
ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095"
|
||||||
|
RESULTS_SERVICE_URL: "http://results-service:8088"
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -390,6 +391,81 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./config/app/base-application.yaml:/workspace/config/application.yml:Z
|
- ./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:
|
networks:
|
||||||
meldestelle-network:
|
meldestelle-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -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.
|
||||||
+5
@@ -31,4 +31,9 @@ object ApiRoutes {
|
|||||||
object Events {
|
object Events {
|
||||||
const val ROOT = "/api/v1/events"
|
const val ROOT = "/api/v1/events"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object Results {
|
||||||
|
const val ROOT = "/api/v1/results"
|
||||||
|
fun bewerb(bewerbId: String) = "$ROOT/bewerb/$bewerbId"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -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<List<Ergebnis>>
|
||||||
|
suspend fun save(ergebnis: Ergebnis): Result<Ergebnis>
|
||||||
|
suspend fun calculatePlatzierung(bewerbId: String): Result<List<Ergebnis>>
|
||||||
|
}
|
||||||
+46
-1
@@ -28,6 +28,7 @@ data class StartlistenZeile(
|
|||||||
val reiter: String,
|
val reiter: String,
|
||||||
val pferd: String,
|
val pferd: String,
|
||||||
val wunsch: String,
|
val wunsch: String,
|
||||||
|
val nennungId: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BewerbState(
|
data class BewerbState(
|
||||||
@@ -49,8 +50,11 @@ data class BewerbState(
|
|||||||
val isAuditLoading: Boolean = false,
|
val isAuditLoading: Boolean = false,
|
||||||
val exportContent: String? = null,
|
val exportContent: String? = null,
|
||||||
val showExportDialog: Boolean = false,
|
val showExportDialog: Boolean = false,
|
||||||
|
val ergebnisse: List<at.mocode.turnier.feature.domain.Ergebnis> = emptyList(),
|
||||||
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
|
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
|
||||||
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
|
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
|
||||||
|
val editingErgebnis: at.mocode.turnier.feature.domain.Ergebnis? = null,
|
||||||
|
val selectedZeile: StartlistenZeile? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface BewerbIntent {
|
sealed interface BewerbIntent {
|
||||||
@@ -79,12 +83,17 @@ sealed interface BewerbIntent {
|
|||||||
data class LoadAuditLog(val bewerbId: Long) : BewerbIntent
|
data class LoadAuditLog(val bewerbId: Long) : BewerbIntent
|
||||||
data object ExportZnsBSatz : BewerbIntent
|
data object ExportZnsBSatz : BewerbIntent
|
||||||
data object CloseExportDialog : 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(
|
class BewerbViewModel(
|
||||||
private val repo: BewerbRepository,
|
private val repo: BewerbRepository,
|
||||||
private val startlistenRepo: StartlistenRepository,
|
private val startlistenRepo: StartlistenRepository,
|
||||||
|
private val ergebnisRepo: at.mocode.turnier.feature.domain.ErgebnisRepository,
|
||||||
private val syncManager: SyncManager? = null,
|
private val syncManager: SyncManager? = null,
|
||||||
private val turnierId: Long,
|
private val turnierId: Long,
|
||||||
) {
|
) {
|
||||||
@@ -134,7 +143,12 @@ class BewerbViewModel(
|
|||||||
when (intent) {
|
when (intent) {
|
||||||
is BewerbIntent.Load, is BewerbIntent.Refresh -> load()
|
is BewerbIntent.Load, is BewerbIntent.Refresh -> load()
|
||||||
is BewerbIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
|
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.ClearError -> reduce { it.copy(errorMessage = null) }
|
||||||
|
|
||||||
is BewerbIntent.OpenDialog -> {
|
is BewerbIntent.OpenDialog -> {
|
||||||
@@ -173,6 +187,37 @@ class BewerbViewModel(
|
|||||||
is BewerbIntent.LoadAuditLog -> loadAuditLog(intent.bewerbId)
|
is BewerbIntent.LoadAuditLog -> loadAuditLog(intent.bewerbId)
|
||||||
is BewerbIntent.ExportZnsBSatz -> exportZnsBSatz()
|
is BewerbIntent.ExportZnsBSatz -> exportZnsBSatz()
|
||||||
is BewerbIntent.CloseExportDialog -> reduce { it.copy(showExportDialog = false, exportContent = null) }
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
@@ -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<List<Ergebnis>> = runCatching {
|
||||||
|
client.get(ApiRoutes.Results.bewerb(bewerbId)).body()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(ergebnis: Ergebnis): Result<Ergebnis> = 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<List<Ergebnis>> = runCatching {
|
||||||
|
client.post("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/calculate").body()
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -18,6 +18,7 @@ val turnierFeatureModule = module {
|
|||||||
single<StartlistenRepository> { DefaultStartlistenRepository(client = get(qualifier = named("apiClient"))) }
|
single<StartlistenRepository> { DefaultStartlistenRepository(client = get(qualifier = named("apiClient"))) }
|
||||||
single<at.mocode.turnier.feature.domain.NennungRepository> { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) }
|
single<at.mocode.turnier.feature.domain.NennungRepository> { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) }
|
||||||
single<at.mocode.turnier.feature.domain.MasterdataRepository> { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) }
|
single<at.mocode.turnier.feature.domain.MasterdataRepository> { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) }
|
||||||
|
single<at.mocode.turnier.feature.domain.ErgebnisRepository> { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) }
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
factory { TurnierViewModel(repo = get()) }
|
factory { TurnierViewModel(repo = get()) }
|
||||||
@@ -26,6 +27,7 @@ val turnierFeatureModule = module {
|
|||||||
BewerbViewModel(
|
BewerbViewModel(
|
||||||
repo = get(),
|
repo = get(),
|
||||||
startlistenRepo = get(),
|
startlistenRepo = get(),
|
||||||
|
ergebnisRepo = get(),
|
||||||
syncManager = getOrNull<SyncManager>(),
|
syncManager = getOrNull<SyncManager>(),
|
||||||
turnierId = turnierId
|
turnierId = turnierId
|
||||||
)
|
)
|
||||||
|
|||||||
+71
-2
@@ -1,12 +1,81 @@
|
|||||||
package at.mocode.turnier.feature.presentation
|
package at.mocode.turnier.feature.presentation
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import at.mocode.turnier.feature.domain.Pferd
|
import at.mocode.turnier.feature.domain.Pferd
|
||||||
import at.mocode.turnier.feature.domain.Reiter
|
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
|
@Composable
|
||||||
fun ReiterEditDialog(
|
fun ReiterEditDialog(
|
||||||
|
|||||||
+46
-18
@@ -11,6 +11,8 @@ 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 at.mocode.turnier.feature.domain.Bewerb
|
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
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
private val ElBlue = Color(0xFF1E3A8A)
|
private val ElBlue = Color(0xFF1E3A8A)
|
||||||
@@ -35,7 +37,9 @@ fun ErgebnislistenTabContent(
|
|||||||
ErgebnislistenBewerbsTabs(
|
ErgebnislistenBewerbsTabs(
|
||||||
bewerbe = state.list,
|
bewerbe = state.list,
|
||||||
selectedId = state.selectedId,
|
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(
|
private fun ErgebnislistenBewerbsTabs(
|
||||||
bewerbe: List<Bewerb>,
|
bewerbe: List<Bewerb>,
|
||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long?) -> Unit
|
onSelect: (Long?) -> Unit,
|
||||||
|
ergebnisse: List<Ergebnis>,
|
||||||
|
startliste: List<StartlistenZeile>
|
||||||
) {
|
) {
|
||||||
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
|
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
|
||||||
|
|
||||||
@@ -121,25 +127,47 @@ private fun ErgebnislistenBewerbsTabs(
|
|||||||
}
|
}
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
// Leere Liste
|
if (ergebnisse.isEmpty()) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
// Leere Liste
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text("Keine Ergebnisse vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Text("Keine Ergebnisse vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
|
||||||
Text("Ergebnisse werden nach dem Turnier eingetragen.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
|
Spacer(Modifier.height(8.dp))
|
||||||
Spacer(Modifier.height(16.dp))
|
Text("Ergebnisse werden nach dem Turnier eingetragen.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Spacer(Modifier.height(16.dp))
|
||||||
OutlinedButton(onClick = {}) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("Ergebnisse importieren", fontSize = 13.sp)
|
OutlinedButton(onClick = {}) {
|
||||||
}
|
Text("Ergebnisse importieren", fontSize = 13.sp)
|
||||||
Button(
|
}
|
||||||
onClick = {},
|
Button(
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
|
onClick = {},
|
||||||
) {
|
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
|
||||||
Text("Ergebnisse eingeben", fontSize = 13.sp)
|
) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-3
@@ -1,6 +1,7 @@
|
|||||||
package at.mocode.turnier.feature.presentation
|
package at.mocode.turnier.feature.presentation
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -38,7 +39,8 @@ fun StartlistenTabContent(
|
|||||||
selectedId = state.selectedId,
|
selectedId = state.selectedId,
|
||||||
onSelect = { viewModel.send(BewerbIntent.Select(it)) },
|
onSelect = { viewModel.send(BewerbIntent.Select(it)) },
|
||||||
currentStartliste = state.currentStartliste,
|
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 ─────────────────────────────────
|
// ── Rechte Spalte: Sortierung & Zeit ─────────────────────────────────
|
||||||
StartlistenSortierPanel(modifier = Modifier.width(280.dp).fillMaxHeight())
|
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
|
@Composable
|
||||||
@@ -55,7 +71,8 @@ private fun StartlistenBewerbsTabs(
|
|||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long?) -> Unit,
|
onSelect: (Long?) -> Unit,
|
||||||
currentStartliste: List<StartlistenZeile>,
|
currentStartliste: List<StartlistenZeile>,
|
||||||
onGenerate: () -> Unit
|
onGenerate: () -> Unit,
|
||||||
|
onRowClick: (StartlistenZeile) -> Unit
|
||||||
) {
|
) {
|
||||||
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
|
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
|
||||||
|
|
||||||
@@ -139,7 +156,10 @@ private fun StartlistenBewerbsTabs(
|
|||||||
items(currentStartliste.size) { index ->
|
items(currentStartliste.size) { index ->
|
||||||
val zeile = currentStartliste[index]
|
val zeile = currentStartliste[index]
|
||||||
Row(
|
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
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(zeile.nr.toString(), fontSize = 12.sp, modifier = Modifier.width(70.dp))
|
Text(zeile.nr.toString(), fontSize = 12.sp, modifier = Modifier.width(70.dp))
|
||||||
|
|||||||
-2
@@ -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.data.KtorVereinRepository
|
||||||
import at.mocode.frontend.features.verein.domain.VereinRepository
|
import at.mocode.frontend.features.verein.domain.VereinRepository
|
||||||
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
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.core.module.dsl.viewModelOf
|
||||||
import org.koin.dsl.bind
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val vereinFeatureModule = module {
|
val vereinFeatureModule = module {
|
||||||
|
|||||||
+6
@@ -149,9 +149,15 @@ fun PreviewTurnierBewerbeTab() {
|
|||||||
override suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
|
override suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
|
||||||
override suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
|
override suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
|
||||||
}
|
}
|
||||||
|
val mockErgebnisRepo = object : at.mocode.turnier.feature.domain.ErgebnisRepository {
|
||||||
|
override suspend fun getForBewerb(bewerbId: String): Result<List<at.mocode.turnier.feature.domain.Ergebnis>> = Result.success(emptyList())
|
||||||
|
override suspend fun save(ergebnis: at.mocode.turnier.feature.domain.Ergebnis): Result<at.mocode.turnier.feature.domain.Ergebnis> = Result.success(ergebnis)
|
||||||
|
override suspend fun calculatePlatzierung(bewerbId: String): Result<List<at.mocode.turnier.feature.domain.Ergebnis>> = Result.success(emptyList())
|
||||||
|
}
|
||||||
val vm = BewerbViewModel(
|
val vm = BewerbViewModel(
|
||||||
repo = mockRepo,
|
repo = mockRepo,
|
||||||
startlistenRepo = mockStartlistenRepo,
|
startlistenRepo = mockStartlistenRepo,
|
||||||
|
ergebnisRepo = mockErgebnisRepo,
|
||||||
syncManager = null,
|
syncManager = null,
|
||||||
turnierId = 1L
|
turnierId = 1L
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user