diff --git a/docs/99_Journal/2026-04-20_V2-Altlasten-Cleanup.md b/docs/99_Journal/2026-04-20_V2-Altlasten-Cleanup.md new file mode 100644 index 00000000..c58dceef --- /dev/null +++ b/docs/99_Journal/2026-04-20_V2-Altlasten-Cleanup.md @@ -0,0 +1,39 @@ +# Journal-Eintrag: Bereinigung der V2-Altlasten und UI-Konsolidierung + +**Datum:** 20. April 2026 +**Agent:** 🧹 [Curator] & 🏗️ [Lead Architect] + +## 🎯 Zielsetzung +Vollständige Eliminierung aller verbliebenen "V2"-Suffixe im Frontend (Feature-Module) und Überführung der "Vision_03"-Verbesserungen in die finalen Produktions-Screens. + +## 🛠️ Durchgeführte Änderungen + +### 1. Turnier-Management (`turnier-feature`) +* **Gelöscht:** `TurnierWizardV2.kt` (und zugehöriges ViewModel). +* **Konsolidiert:** `TurnierNeuScreen.kt` wurde zum primären Wizard für die Turnieranlage ausgebaut. + * Übernahme des **Stepper-Designs** (StepCircle) für die Tab-Leiste. + * Beibehaltung der **nicht-linearen Tab-Navigation** für maximale Effizienz bei Profi-Usern. + * Integration einer **Footer-Navigation** (Abbrechen, Zurück, Weiter, Finalisieren). + * Visuelles Alignment auf das **PrimaryBlue (#1E3A8A)**. + +### 2. Veranstalter-Management (`veranstalter-feature`) +* **Gelöscht:** `VeranstalterAuswahlV2.kt`. +* **Konsolidiert:** `VeranstalterAuswahlScreen.kt` modernisiert. + * Kombination der **dichten Tabellenansicht** (Profi-Anforderung) mit dem modernen Card-Styling aus V2. + * Einführung von **Radio-Buttons** zur expliziten Auswahl eines Veranstalters. + * Integration der fachlichen **Hinweis-Box** (OEPS-Registrierung, Login-Generierung). + * Alignment der Top-Bar und Footer-Aktionen auf Vision_03 Standards. + +### 3. Code-Hygiene +* Bereinigung veralteter Kommentare in `TurnierStammdatenTab.kt`, die auf `StoreV2` oder `TurnierStoreV2` verwiesen. +* Fix von Unchecked-Casts im `TurnierStammdatenTab.kt` zur Verbesserung der Typsicherheit beim Zugriff auf Mock-Daten. + +## ✅ Verifikation +* **Build:** `:frontend:shells:meldestelle-desktop:compileKotlinJvm` erfolgreich durchgelaufen. +* **Struktur:** Keine Dateien mit `*V2.kt` mehr in den Feature-Modulen vorhanden. + +## 📝 Hinweis +Die Einstellung `enableWasm=false` in `gradle.properties` bleibt vorerst aktiv, um die Iterationsgeschwindigkeit für die Desktop-Entwicklung hoch zu halten. Vor dem Release der Web-App muss dieser Flag wieder auf `true` gesetzt werden. + +--- +*Gezeichnet: Junie (KI-Agent)* diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/mapper/TurnierMapper.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/mapper/TurnierMapper.kt new file mode 100644 index 00000000..461eb8a7 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/mapper/TurnierMapper.kt @@ -0,0 +1,52 @@ +package at.mocode.frontend.features.turnier.data.mapper + +import at.mocode.frontend.features.turnier.data.remote.dto.AbteilungDto +import at.mocode.frontend.features.turnier.data.remote.dto.AbteilungsWarnungDto +import at.mocode.frontend.features.turnier.data.remote.dto.BewerbDto +import at.mocode.frontend.features.turnier.data.remote.dto.TurnierDto +import at.mocode.frontend.features.turnier.domain.Abteilung +import at.mocode.frontend.features.turnier.domain.AbteilungsWarnung +import at.mocode.frontend.features.turnier.domain.Bewerb +import at.mocode.frontend.features.turnier.domain.Turnier + +fun TurnierDto.toDomain(): Turnier = Turnier(id = id, name = name) +fun Turnier.toDto(): TurnierDto = TurnierDto(id = id, name = name) + +fun BewerbDto.toDomain(): Bewerb = Bewerb( + id = id, + turnierId = turnierId, + tag = tag, + platz = platz, + name = name, + sparte = sparte, + klasse = klasse, + nennungen = nennungen, + geplantesDatum = geplantesDatum, + beginnZeit = beginnZeit, + reitdauerMinuten = reitdauerMinuten, + umbauMinuten = umbauMinuten, + besichtigungMinuten = besichtigungMinuten, + austragungsplatzId = austragungsplatzId, + warnungen = warnungen.map { AbteilungsWarnung(it.code, it.nachricht, it.oetoParagraph) } +) + +fun Bewerb.toDto(): BewerbDto = BewerbDto( + id = id, + turnierId = turnierId, + tag = tag, + platz = platz, + name = name, + sparte = sparte, + klasse = klasse, + nennungen = nennungen, + geplantesDatum = geplantesDatum, + beginnZeit = beginnZeit, + reitdauerMinuten = reitdauerMinuten, + umbauMinuten = umbauMinuten, + besichtigungMinuten = besichtigungMinuten, + austragungsplatzId = austragungsplatzId, + warnungen = warnungen.map { AbteilungsWarnungDto(it.code, it.nachricht, it.oetoParagraph) } +) + +fun AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name) +fun Abteilung.toDto(): AbteilungDto = AbteilungDto(id = id, bewerbId = bewerbId, name = name) diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/BewerbApi.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/BewerbApi.kt new file mode 100644 index 00000000..17f3cd41 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/BewerbApi.kt @@ -0,0 +1,87 @@ +package at.mocode.frontend.features.turnier.data.remote + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RichterEinsatzDto( + val funktionaerId: String, + val position: String, +) + +@Serializable +data class CreateBewerbPayload( + // Basis + val klasse: String, + val hoeheCm: Int? = null, + val bezeichnung: String, + + // Text & Details + val beschreibung: String? = null, + val aufgabe: String? = null, + val aufgabenNummer: String? = null, + val paraGrade: String? = null, + + // Ort & Funktionäre + val austragungsplatzId: String? = null, + val richterEinsaetze: List = emptyList(), + + // Zeitplan + val geplantesDatum: LocalDate? = null, + @SerialName("beginnZeitTyp") val beginnZeitTyp: String? = null, // enum name + val beginnZeit: LocalTime? = null, + val reitdauerMinuten: Int? = null, + val umbauMinuten: Int? = null, + val besichtigungMinuten: Int? = null, + val stechenGeplant: Boolean = false, + + // Finanzen + val startgeldCent: Long? = null, + val geldpreisAusbezahlt: Boolean = false, +) + +@Serializable +data class BewerbResponse( + val id: String, + val turnierId: String, + val klasse: String, + val hoeheCm: Int? = null, + val bezeichnung: String, + + // Text & Details + val beschreibung: String? = null, + val aufgabe: String? = null, + val aufgabenNummer: String? = null, + val paraGrade: String? = null, + + // Ort & Funktionäre + val austragungsplatzId: String? = null, + val richterEinsaetze: List = emptyList(), + + // Zeitplan + val geplantesDatum: LocalDate? = null, + @SerialName("beginnZeitTyp") val beginnZeitTyp: String? = null, + val beginnZeit: LocalTime? = null, + val reitdauerMinuten: Int? = null, + val umbauMinuten: Int? = null, + val besichtigungMinuten: Int? = null, + val stechenGeplant: Boolean = false, + + // Finanzen + val startgeldCent: Long? = null, + val geldpreisAusbezahlt: Boolean = false, +) + +class BewerbApi(private val apiClient: HttpClient) { + suspend fun createBewerb(turnierId: String, payload: CreateBewerbPayload): BewerbResponse = + apiClient.post("/turniere/$turnierId/bewerbe") { + contentType(ContentType.Application.Json) + setBody(payload) + }.body() +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultAbteilungRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultAbteilungRepository.kt new file mode 100644 index 00000000..7bd0b290 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultAbteilungRepository.kt @@ -0,0 +1,71 @@ +package at.mocode.frontend.features.turnier.data.remote + +import at.mocode.frontend.core.network.* +import at.mocode.frontend.features.turnier.data.mapper.toDomain +import at.mocode.frontend.features.turnier.data.mapper.toDto +import at.mocode.frontend.features.turnier.data.remote.dto.AbteilungDto +import at.mocode.frontend.features.turnier.domain.Abteilung +import at.mocode.frontend.features.turnier.domain.AbteilungRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class DefaultAbteilungRepository( + private val client: HttpClient, +) : AbteilungRepository { + + override suspend fun list(bewerbId: Long): Result> = runCatching { + val response = client.get(ApiRoutes.Bewerbe.abteilungen(bewerbId)) + when { + response.status.isSuccess() -> response.body>().map { it.toDomain() } + response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() + response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun getById(id: Long): Result = runCatching { + val response = client.get("${ApiRoutes.API_PREFIX}/abteilungen/$id") + when { + response.status.isSuccess() -> response.body().toDomain() + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() + response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun create(model: Abteilung): Result = runCatching { + val response = client.post(ApiRoutes.Bewerbe.abteilungen(model.bewerbId)) { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().toDomain() + response.status == HttpStatusCode.Conflict -> throw Conflict() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun update(id: Long, model: Abteilung): Result = runCatching { + val response = client.put("${ApiRoutes.API_PREFIX}/abteilungen/$id") { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().toDomain() + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status == HttpStatusCode.Conflict -> throw Conflict() + 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}/abteilungen/$id") + when { + response.status.isSuccess() -> Unit + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultBewerbRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultBewerbRepository.kt new file mode 100644 index 00000000..da73b7e7 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultBewerbRepository.kt @@ -0,0 +1,127 @@ +package at.mocode.frontend.features.turnier.data.remote + +import at.mocode.frontend.core.network.* +import at.mocode.frontend.features.turnier.data.mapper.toDomain +import at.mocode.frontend.features.turnier.data.mapper.toDto +import at.mocode.frontend.features.turnier.data.remote.dto.BewerbDto +import at.mocode.frontend.features.turnier.domain.AuditLogEntry +import at.mocode.frontend.features.turnier.domain.Bewerb +import at.mocode.frontend.features.turnier.domain.BewerbRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class DefaultBewerbRepository( + private val client: HttpClient, +) : BewerbRepository { + + override suspend fun list(turnierId: Long): Result> = runCatching { + val response = client.get(ApiRoutes.Turniere.bewerbe(turnierId)) + when { + response.status.isSuccess() -> response.body>().map { it.toDomain() } + response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() + response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun importBewerbe(turnierId: Long, bewerbe: List): Result = + runCatching { + val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") { + setBody(bewerbe) + } + when { + response.status.isSuccess() -> Unit + response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() + response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun getById(id: Long): Result = runCatching { + val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id") + when { + response.status.isSuccess() -> response.body().toDomain() + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() + response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun create(model: Bewerb): Result = runCatching { + val response = client.post(ApiRoutes.Turniere.bewerbe(model.turnierId)) { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().toDomain() + response.status == HttpStatusCode.Conflict -> throw Conflict() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun update(id: Long, model: Bewerb): Result = runCatching { + val response = client.put("${ApiRoutes.API_PREFIX}/bewerbe/$id") { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().toDomain() + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status == HttpStatusCode.Conflict -> throw Conflict() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + 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 getAuditLog(bewerbId: Long): Result> = runCatching { + val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/audit-log") + when { + response.status.isSuccess() -> response.body>() + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun exportZnsBSatz(turnierId: Long): Result = runCatching { + val response = client.get("${ApiRoutes.API_PREFIX}/turniere/$turnierId/export/zns/b-satz") + when { + response.status.isSuccess() -> response.body() + response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() + response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() + 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 { + response.status.isSuccess() -> Unit + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultErgebnisRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultErgebnisRepository.kt new file mode 100644 index 00000000..16fe9256 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultErgebnisRepository.kt @@ -0,0 +1,40 @@ +package at.mocode.frontend.features.turnier.data.remote + +import at.mocode.frontend.core.network.ApiRoutes +import at.mocode.frontend.features.turnier.domain.Ergebnis +import at.mocode.frontend.features.turnier.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() + } + + override suspend fun exportPdf(bewerbId: String): Result = runCatching { + client.get("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/pdf").body() + } +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultMasterdataRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultMasterdataRepository.kt new file mode 100644 index 00000000..2739f453 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultMasterdataRepository.kt @@ -0,0 +1,166 @@ +package at.mocode.frontend.features.turnier.data.remote + +import at.mocode.frontend.core.network.ApiRoutes +import at.mocode.frontend.features.turnier.domain.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.serialization.Serializable + +class DefaultMasterdataRepository( + private val client: HttpClient +) : MasterdataRepository { + + override suspend fun searchReiter(query: String): Result> = runCatching { + val response = client.get("${ApiRoutes.Masterdata.REITER}/search") { + parameter("q", query) + } + if (response.status.isSuccess()) { + // Wir mappen hier manuell, da die Features aktuell keine DTOs exportieren + response.body>().map { it.toDomain() } + } else emptyList() + } + + override suspend fun getReiter(id: String): Result = runCatching { + val response = client.get("${ApiRoutes.Masterdata.REITER}/$id") + if (response.status.isSuccess()) { + response.body().toDomain() + } else throw Exception("Reiter nicht gefunden") + } + + override suspend fun saveReiter(reiter: Reiter): Result = runCatching { + val response = client.put("${ApiRoutes.Masterdata.REITER}/${reiter.id}") { + contentType(ContentType.Application.Json) + setBody(ReiterApiDto( + reiterId = reiter.id, + vorname = reiter.vorname, + nachname = reiter.nachname, + satznummer = reiter.satznummer, + vereinsName = reiter.verein, + feiId = reiter.feiId + )) + } + if (response.status.isSuccess()) { + response.body().toDomain() + } else throw Exception("Fehler beim Speichern des Reiters") + } + + override suspend fun searchPferde(query: String): Result> = runCatching { + val response = client.get("${ApiRoutes.Masterdata.PFERDE}/search") { + parameter("q", query) + } + if (response.status.isSuccess()) { + response.body>().map { it.toDomain() } + } else emptyList() + } + + override suspend fun getPferd(id: String): Result = runCatching { + val response = client.get("${ApiRoutes.Masterdata.PFERDE}/$id") + if (response.status.isSuccess()) { + response.body().toDomain() + } else throw Exception("Pferd nicht gefunden") + } + + override suspend fun savePferd(pferd: Pferd): Result = runCatching { + val response = client.put("${ApiRoutes.Masterdata.PFERDE}/${pferd.id}") { + contentType(ContentType.Application.Json) + setBody(HorseApiDto( + pferdId = pferd.id, + pferdeName = pferd.name, + lebensnummer = pferd.lebensnummer, + geschlecht = "UNBEKANNT", // Fallback + geburtsjahr = pferd.geburtsjahr, + satznummer = pferd.oepsNummer + )) + } + if (response.status.isSuccess()) { + response.body().toDomain() + } else throw Exception("Fehler beim Speichern des Pferdes") + } + + override suspend fun searchFunktionaere(query: String): Result> = runCatching { + val response = client.get("${ApiRoutes.Masterdata.FUNKTIONAERE}/search") { + parameter("q", query) + } + if (response.status.isSuccess()) { + response.body>().map { + Funktionaer(it.funktionaerId, it.name ?: "Unbekannt", it.qualifikationen, it.istAktiv) + } + } else emptyList() + } + + override suspend fun listVereine(): Result> = runCatching { + val response = client.get(ApiRoutes.Masterdata.VEREINE) + if (response.status.isSuccess()) { + response.body>().map { + Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter) + } + } else emptyList() + } + + override suspend fun getVereinById(id: String): Result = runCatching { + val response = client.get("${ApiRoutes.Masterdata.VEREINE}/$id") + if (response.status.isSuccess()) { + val it = response.body() + Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter) + } else throw Exception("Verein nicht gefunden") + } + + // Interne Hilfs-DTOs für das Mapping der Masterdata-API + @Serializable + private data class ReiterApiDto( + val reiterId: String, + val vorname: String, + val nachname: String, + val satznummer: String? = null, + val vereinsName: String? = null, + val feiId: String? = null, + val reiterLizenz: String? = null + ) { + fun toDomain() = Reiter( + id = reiterId, + vorname = vorname, + nachname = nachname, + satznummer = satznummer, + verein = vereinsName, + feiId = feiId, + oepsNummer = satznummer + ) + } + + @Serializable + private data class HorseApiDto( + val pferdId: String, + val pferdeName: String, + val lebensnummer: String? = null, + val geschlecht: String, + val geburtsjahr: Int? = null, + val satznummer: String? = null + ) { + fun toDomain() = Pferd( + id = pferdId, + name = pferdeName, + lebensnummer = lebensnummer ?: "", + geburtsjahr = geburtsjahr, + oepsNummer = satznummer + ) + } + + @Serializable + private data class FunktionaerApiDto( + val funktionaerId: String, + val name: String? = null, + val qualifikationen: List = emptyList(), + val istAktiv: Boolean + ) + + @Serializable + private data class VereinApiDto( + val vereinId: String, + val vereinsNummer: String, + val name: String, + val ort: String? = null, + val istVeranstalter: Boolean + ) +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultNennungRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultNennungRepository.kt new file mode 100644 index 00000000..5e14d1ec --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultNennungRepository.kt @@ -0,0 +1,88 @@ +package at.mocode.frontend.features.turnier.data.remote + +import at.mocode.frontend.core.network.* +import at.mocode.frontend.features.turnier.data.remote.dto.* +import at.mocode.frontend.features.turnier.domain.Nennung +import at.mocode.frontend.features.turnier.domain.NennungRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class DefaultNennungRepository( + private val client: HttpClient +) : NennungRepository { + + override suspend fun list(turnierId: Long): Result> = runCatching { + val response = client.get("${ApiRoutes.Turniere.ROOT}/$turnierId/nennungen") + if (response.status.isSuccess()) { + response.body>().map { it.toDomain() } + } else { + throw HttpError(response.status.value) + } + } + + override suspend fun listByBewerb(bewerbId: Long): Result> = runCatching { + val response = client.get(ApiRoutes.Bewerbe.nennungen(bewerbId)) + if (response.status.isSuccess()) { + response.body>().map { it.toDomain() } + } else { + throw HttpError(response.status.value) + } + } + + override suspend fun einreichen(request: NennungEinreichenRequest): Result = runCatching { + val response = client.post(ApiRoutes.Bewerbe.nennungen(request.bewerbId.toLong())) { + contentType(ContentType.Application.Json) + setBody(request) + } + if (response.status.isSuccess()) { + response.body().toDomain() + } else { + throw HttpError(response.status.value) + } + } + + override suspend fun updateStatus(id: String, status: String): Result = runCatching { + val response = client.patch("${ApiRoutes.API_PREFIX}/nennungen/$id/status") { + contentType(ContentType.Application.Json) + setBody(mapOf("status" to status)) + } + if (response.status.isSuccess()) { + response.body().toDomain() + } else { + throw HttpError(response.status.value) + } + } + + override suspend fun delete(id: String): Result = runCatching { + val response = client.delete("${ApiRoutes.API_PREFIX}/nennungen/$id") + if (!response.status.isSuccess()) { + throw HttpError(response.status.value) + } + } + + private fun NennungSummaryDto.toDomain() = Nennung( + id = nennungId, + turnierId = turnierId, + bewerbId = bewerbId, + abteilungId = abteilungId, + reiterId = reiterId, + pferdId = pferdId, + status = status, + istNachnennung = istNachnennung, + createdAt = createdAt + ) + + private fun NennungDetailDto.toDomain() = Nennung( + id = nennungId, + turnierId = turnierId, + bewerbId = bewerbId, + abteilungId = abteilungId, + reiterId = reiterId, + pferdId = pferdId, + status = status, + istNachnennung = istNachnennung, + createdAt = createdAt + ) +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultSeriesRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultSeriesRepository.kt new file mode 100644 index 00000000..eaddef32 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultSeriesRepository.kt @@ -0,0 +1,41 @@ +package at.mocode.frontend.features.turnier.data.remote + +import at.mocode.frontend.core.network.ApiRoutes +import at.mocode.frontend.features.turnier.domain.Serie +import at.mocode.frontend.features.turnier.domain.SerieStandEntry +import at.mocode.frontend.features.turnier.domain.SeriesRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class DefaultSeriesRepository( + private val client: HttpClient +) : SeriesRepository { + + override suspend fun getAll(): Result> = runCatching { + client.get(ApiRoutes.Series.ROOT).body() + } + + override suspend fun getById(id: String): Result = runCatching { + client.get("${ApiRoutes.Series.ROOT}/$id").body() + } + + override suspend fun save(serie: Serie): Result = runCatching { + if (serie.id == null) { + client.post(ApiRoutes.Series.ROOT) { + contentType(ContentType.Application.Json) + setBody(serie) + }.body() + } else { + client.put("${ApiRoutes.Series.ROOT}/${serie.id}") { + contentType(ContentType.Application.Json) + setBody(serie) + }.body() + } + } + + override suspend fun getStand(serieId: String): Result> = runCatching { + client.get(ApiRoutes.Series.stand(serieId)).body() + } +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultStartlistenRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultStartlistenRepository.kt new file mode 100644 index 00000000..b432e681 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultStartlistenRepository.kt @@ -0,0 +1,34 @@ +package at.mocode.frontend.features.turnier.data.remote + +import at.mocode.frontend.core.network.* +import at.mocode.frontend.features.turnier.domain.StartlistenRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile + +class DefaultStartlistenRepository( + private val client: HttpClient, +) : StartlistenRepository { + + override suspend fun generate(bewerbId: Long): Result> = runCatching { + val response = client.post("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/startliste/generate") + when { + response.status.isSuccess() -> response.body() + response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun getByBewerb(bewerbId: Long): Result> = runCatching { + val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/startliste") + when { + response.status.isSuccess() -> response.body() + response.status == HttpStatusCode.NotFound -> emptyList() + response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() + else -> throw HttpError(response.status.value) + } + } +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultTurnierRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultTurnierRepository.kt new file mode 100644 index 00000000..18b8343f --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/DefaultTurnierRepository.kt @@ -0,0 +1,71 @@ +package at.mocode.frontend.features.turnier.data.remote + +import at.mocode.frontend.core.network.* +import at.mocode.frontend.features.turnier.data.mapper.toDomain +import at.mocode.frontend.features.turnier.data.mapper.toDto +import at.mocode.frontend.features.turnier.data.remote.dto.TurnierDto +import at.mocode.frontend.features.turnier.domain.Turnier +import at.mocode.frontend.features.turnier.domain.TurnierRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class DefaultTurnierRepository( + private val client: HttpClient, +) : TurnierRepository { + + override suspend fun list(): Result> = runCatching { + val response = client.get(ApiRoutes.Turniere.ROOT) + when { + response.status.isSuccess() -> response.body>().map { it.toDomain() } + response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() + response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun getById(id: Long): Result = runCatching { + val response = client.get("${ApiRoutes.Turniere.ROOT}/$id") + when { + response.status.isSuccess() -> response.body().toDomain() + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() + response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun create(model: Turnier): Result = runCatching { + val response = client.post(ApiRoutes.Turniere.ROOT) { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().toDomain() + response.status == HttpStatusCode.Conflict -> throw Conflict() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } + + override suspend fun update(id: Long, model: Turnier): Result = runCatching { + val response = client.put("${ApiRoutes.Turniere.ROOT}/$id") { setBody(model.toDto()) } + when { + response.status.isSuccess() -> response.body().toDomain() + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status == HttpStatusCode.Conflict -> throw Conflict() + 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.Turniere.ROOT}/$id") + when { + response.status.isSuccess() -> Unit + response.status == HttpStatusCode.NotFound -> throw NotFound() + response.status.value >= 500 -> throw ServerError() + else -> throw HttpError(response.status.value) + } + } +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/dto/NennungDto.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/dto/NennungDto.kt new file mode 100644 index 00000000..00afdcf8 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/dto/NennungDto.kt @@ -0,0 +1,49 @@ +package at.mocode.frontend.features.turnier.data.remote.dto + +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi + +@OptIn(ExperimentalUuidApi::class) +@Serializable +data class NennungSummaryDto( + val nennungId: String, + val turnierId: String, + val bewerbId: String, + val abteilungId: String, + val reiterId: String, + val pferdId: String, + val status: String, + val istNachnennung: Boolean, + val createdAt: String +) + +@OptIn(ExperimentalUuidApi::class) +@Serializable +data class NennungDetailDto( + val nennungId: String, + val abteilungId: String, + val bewerbId: String, + val turnierId: String, + val reiterId: String, + val pferdId: String, + val zahlerId: String? = null, + val status: String, + val startwunsch: String, + val istNachnennung: Boolean, + val bemerkungen: String? = null, + val createdAt: String, + val updatedAt: String +) + +@Serializable +data class NennungEinreichenRequest( + val abteilungId: String, + val bewerbId: String, + val turnierId: String, + val reiterId: String, + val pferdId: String, + val zahlerId: String? = null, + val startwunsch: String = "KEIN_WUNSCH", + val istNachnennung: Boolean = false, + val bemerkungen: String? = null +) diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/dto/TurnierDto.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/dto/TurnierDto.kt new file mode 100644 index 00000000..f2206255 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/data/remote/dto/TurnierDto.kt @@ -0,0 +1,42 @@ +package at.mocode.frontend.features.turnier.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class TurnierDto( + val id: Long, + val name: String, +) + +@Serializable +data class BewerbDto( + val id: Long, + val turnierId: Long, + val tag: String, + val platz: Int, + val name: String, + 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, + val warnungen: List = emptyList(), +) + +@Serializable +data class AbteilungsWarnungDto( + val code: String, + val nachricht: String, + val oetoParagraph: String? = null, +) + +@Serializable +data class AbteilungDto( + val id: Long, + val bewerbId: Long, + val name: String, +) diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/AbteilungRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/AbteilungRepository.kt new file mode 100644 index 00000000..a997d7eb --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/AbteilungRepository.kt @@ -0,0 +1,15 @@ +package at.mocode.frontend.features.turnier.domain + +data class Abteilung( + val id: Long, + val bewerbId: Long, + val name: String, +) + +interface AbteilungRepository { + suspend fun list(bewerbId: Long): Result> + suspend fun getById(id: Long): Result + suspend fun create(model: Abteilung): Result + suspend fun update(id: Long, model: Abteilung): Result + suspend fun delete(id: Long): Result +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/BewerbRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/BewerbRepository.kt new file mode 100644 index 00000000..79d31efe --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/BewerbRepository.kt @@ -0,0 +1,50 @@ +package at.mocode.frontend.features.turnier.domain + +import at.mocode.zns.parser.ZnsBewerb + +data class Bewerb( + val id: Long, + val turnierId: Long, + val tag: String, + val platz: Int, + val name: String, + val sparte: String, + 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( + val code: String, + val nachricht: String, + val oetoParagraph: String? +) + +data class AuditLogEntry( + val id: String, + val entityType: String, + val entityId: String, + val action: String, + val userId: String?, + val timestamp: String, + val changesJson: String? +) + +interface BewerbRepository { + suspend fun list(turnierId: Long): Result> + 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 getAuditLog(bewerbId: Long): Result> + suspend fun exportZnsBSatz(turnierId: Long): 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/frontend/features/turnier/domain/ErgebnisRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/ErgebnisRepository.kt new file mode 100644 index 00000000..75de2e2e --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/ErgebnisRepository.kt @@ -0,0 +1,27 @@ +package at.mocode.frontend.features.turnier.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> + suspend fun exportPdf(bewerbId: String): Result +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/MasterdataRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/MasterdataRepository.kt new file mode 100644 index 00000000..415a6fe6 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/MasterdataRepository.kt @@ -0,0 +1,50 @@ +package at.mocode.frontend.features.turnier.domain + +data class Reiter( + val id: String, + val vorname: String, + val nachname: String, + val satznummer: String? = null, + val verein: String? = null, + val feiId: String? = null, + val oepsNummer: String? = null +) { + val name: String get() = "$vorname $nachname" +} + +data class Pferd( + val id: String, + val name: String, + val lebensnummer: String, + val geburtsjahr: Int? = null, + val oepsNummer: String? = null +) + +data class Funktionaer( + val id: String, + val name: String, + val qualifikationen: List, + val istAktiv: Boolean +) + +data class Verein( + val id: String, + val name: String, + val vereinsNummer: String, + val ort: String?, + val istVeranstalter: Boolean +) + +interface MasterdataRepository { + suspend fun searchReiter(query: String): Result> + suspend fun getReiter(id: String): Result + suspend fun saveReiter(reiter: Reiter): Result + + suspend fun searchPferde(query: String): Result> + suspend fun getPferd(id: String): Result + suspend fun savePferd(pferd: Pferd): Result + + suspend fun searchFunktionaere(query: String): Result> + suspend fun listVereine(): Result> + suspend fun getVereinById(id: String): Result +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/NennungRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/NennungRepository.kt new file mode 100644 index 00000000..0f154726 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/NennungRepository.kt @@ -0,0 +1,25 @@ +package at.mocode.frontend.features.turnier.domain + +data class Nennung( + val id: String, + val turnierId: String, + val bewerbId: String, + val abteilungId: String, + val reiterId: String, + val pferdId: String, + val status: String, + val istNachnennung: Boolean, + val createdAt: String, + // Erweiterte Infos für UI + val reiterName: String? = null, + val pferdeName: String? = null, + val bewerbName: String? = null +) + +interface NennungRepository { + suspend fun list(turnierId: Long): Result> + suspend fun listByBewerb(bewerbId: Long): Result> + suspend fun einreichen(request: at.mocode.frontend.features.turnier.data.remote.dto.NennungEinreichenRequest): Result + suspend fun updateStatus(id: String, status: String): Result + suspend fun delete(id: String): Result +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/SeriesRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/SeriesRepository.kt new file mode 100644 index 00000000..c49aab8e --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/SeriesRepository.kt @@ -0,0 +1,40 @@ +package at.mocode.frontend.features.turnier.domain + +import kotlinx.serialization.Serializable + +@Serializable +data class Serie( + val id: String? = null, + val name: String, + val beschreibung: String? = null, + val reglementTyp: String = "STREICHER_NORMAL", + val streichresultateCount: Int = 1, + val bindungstyp: String = "PAAR_BINDUNG", + val bewerbIds: Set = emptySet() +) + +@Serializable +data class SerieStandEntry( + val reiterId: String, + val pferdId: String?, + val punkte: Double, + val anzahlWertungen: Int +) + +@Serializable +data class SeriePunkt( + val id: String? = null, + val serieId: String, + val reiterId: String, + val pferdId: String, + val bewerbId: String, + val punkte: Double, + val platzierung: Int +) + +interface SeriesRepository { + suspend fun getAll(): Result> + suspend fun getById(id: String): Result + suspend fun save(serie: Serie): Result + suspend fun getStand(serieId: String): Result> +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/StartlistenRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/StartlistenRepository.kt new file mode 100644 index 00000000..0e42ff87 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/StartlistenRepository.kt @@ -0,0 +1,8 @@ +package at.mocode.frontend.features.turnier.domain + +import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile + +interface StartlistenRepository { + suspend fun generate(bewerbId: Long): Result> + suspend fun getByBewerb(bewerbId: Long): Result> +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/TurnierRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/TurnierRepository.kt new file mode 100644 index 00000000..f0273721 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/TurnierRepository.kt @@ -0,0 +1,14 @@ +package at.mocode.frontend.features.turnier.domain + +data class Turnier( + val id: Long, + val name: String, +) + +interface TurnierRepository { + suspend fun list(): Result> + suspend fun getById(id: Long): Result + suspend fun create(model: Turnier): Result + suspend fun update(id: Long, model: Turnier): Result + suspend fun delete(id: Long): Result +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/model/StartlistenZeile.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/model/StartlistenZeile.kt new file mode 100644 index 00000000..0d3abfb0 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/frontend/features/turnier/domain/model/StartlistenZeile.kt @@ -0,0 +1,13 @@ +package at.mocode.frontend.features.turnier.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class StartlistenZeile( + val nr: Int, + val zeit: String, + val reiter: String, + val pferd: String, + val wunsch: String, + val nennungId: String = "" +) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/AbteilungViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/AbteilungViewModel.kt new file mode 100644 index 00000000..250934c4 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/AbteilungViewModel.kt @@ -0,0 +1,132 @@ +package at.mocode.frontend.features.turnier.presentation + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +data class StartlistenEintrag( + val startNr: Int, + val reiterName: String, + val pferdeName: String, +) + +data class ErgebnisEintrag( + val startNr: Int, + val punkte: Double?, + val rang: Int?, +) + +data class AbteilungState( + val isLoading: Boolean = false, + val errorMessage: String? = null, + val startliste: List = emptyList(), + val ergebnisse: List = emptyList(), +) + +sealed interface AbteilungIntent { + data class LoadByBewerb(val bewerbId: Long, val abteilungsNr: Int) : AbteilungIntent + data object Refresh : AbteilungIntent + data class UpdateErgebnis(val startNr: Int, val punkte: Double?) : AbteilungIntent + data class ReorderStartliste(val fromIndex: Int, val toIndex: Int) : AbteilungIntent + data object Publish : AbteilungIntent + data object ClearError : AbteilungIntent +} + +interface AbteilungRepository { + suspend fun loadStartliste(bewerbId: Long, abteilungsNr: Int): List + suspend fun loadErgebnisse(bewerbId: Long, abteilungsNr: Int): List + suspend fun saveErgebnis(bewerbId: Long, abteilungsNr: Int, startNr: Int, punkte: Double?) + suspend fun saveStartlistenOrder(bewerbId: Long, abteilungsNr: Int, orderedStartNr: List) + suspend fun publish(bewerbId: Long, abteilungsNr: Int) +} + +class AbteilungViewModel( + private val repo: AbteilungRepository, + private var bewerbId: Long, + private var abteilungsNr: Int, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _state = MutableStateFlow(AbteilungState(isLoading = true)) + val state: StateFlow = _state + + init { + send(AbteilungIntent.LoadByBewerb(bewerbId, abteilungsNr)) + } + + fun send(intent: AbteilungIntent) { + when (intent) { + is AbteilungIntent.LoadByBewerb -> { + bewerbId = intent.bewerbId + abteilungsNr = intent.abteilungsNr + load() + } + is AbteilungIntent.Refresh -> load() + is AbteilungIntent.UpdateErgebnis -> updateErgebnis(intent.startNr, intent.punkte) + is AbteilungIntent.ReorderStartliste -> reorder(intent.fromIndex, intent.toIndex) + is AbteilungIntent.Publish -> publish() + is AbteilungIntent.ClearError -> reduce { it.copy(errorMessage = null) } + } + } + + private fun load() { + reduce { it.copy(isLoading = true, errorMessage = null) } + scope.launch { + try { + val start = repo.loadStartliste(bewerbId, abteilungsNr) + val erg = repo.loadErgebnisse(bewerbId, abteilungsNr) + reduce { it.copy(isLoading = false, startliste = start, ergebnisse = erg) } + } catch (t: Throwable) { + reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") } + } + } + } + + private fun updateErgebnis(startNr: Int, punkte: Double?) { + scope.launch { + try { + repo.saveErgebnis(bewerbId, abteilungsNr, startNr, punkte) + // Lokale Spiegelung + val newErg = state.value.ergebnisse.toMutableList() + val idx = newErg.indexOfFirst { it.startNr == startNr } + if (idx >= 0) newErg[idx] = newErg[idx].copy(punkte = punkte) else newErg += ErgebnisEintrag(startNr, punkte, null) + reduce { it.copy(ergebnisse = newErg) } + } catch (t: Throwable) { + reduce { it.copy(errorMessage = t.message ?: "Fehler beim Speichern") } + } + } + } + + private fun reorder(fromIndex: Int, toIndex: Int) { + val list = state.value.startliste.toMutableList() + if (fromIndex in list.indices && toIndex in list.indices) { + val item = list.removeAt(fromIndex) + list.add(toIndex, item) + reduce { it.copy(startliste = list) } + scope.launch { + try { + repo.saveStartlistenOrder(bewerbId, abteilungsNr, list.map { it.startNr }) + } catch (t: Throwable) { + reduce { it.copy(errorMessage = t.message ?: "Fehler beim Speichern der Reihenfolge") } + } + } + } + } + + private fun publish() { + scope.launch { + try { + repo.publish(bewerbId, abteilungsNr) + } catch (t: Throwable) { + reduce { it.copy(errorMessage = t.message ?: "Fehler beim Veröffentlichen") } + } + } + } + + private inline fun reduce(block: (AbteilungState) -> AbteilungState) { + _state.value = block(_state.value) + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/AktorScreens.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/AktorScreens.kt new file mode 100644 index 00000000..2043343f --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/AktorScreens.kt @@ -0,0 +1,73 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import at.mocode.frontend.core.designsystem.models.PlaceholderContent + +/** + * Placeholder-Screens für Akteur-Verwaltung (actor-context). + * Werden in Phase 4/5 mit echten Daten aus dem actor-context befüllt. + */ + +@Composable +fun ReiterScreen() { + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Text("Reiter", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(24.dp)) + PlaceholderContent( + title = "Reiter-Verwaltung", + subtitle = "Satznummer, Lizenzklasse, Sparten-Lizenz – actor-context (Phase 4).", + ) + } +} + +@Composable +fun PferdeScreen() { + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Text("Pferde", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(24.dp)) + PlaceholderContent( + title = "Pferde-Verwaltung", + subtitle = "Lebensnummer, ZNS-Daten, Passbesitzer – actor-context (Phase 4).", + ) + } +} + +@Composable +fun FunktionaereScreen() { + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Text("Funktionäre", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(24.dp)) + PlaceholderContent( + title = "Funktionäre-Verwaltung", + subtitle = "Richter, Parcourschef, Tierarzt – actor-context (Phase 4).", + ) + } +} + +@Composable +fun MeisterschaftenScreen() { + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Text("Meisterschaften", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(24.dp)) + PlaceholderContent( + title = "Meisterschaften", + subtitle = "Konfigurierbare Reglements, Punktesysteme – series-context (Phase 2+).", + ) + } +} + +@Composable +fun CupsScreen() { + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Text("Cups", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(24.dp)) + PlaceholderContent( + title = "Cups & Serien", + subtitle = "Pluggable Berechnungsmodell, Paar-Bindung – series-context (Phase 2+).", + ) + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/BewerbAnlegenViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/BewerbAnlegenViewModel.kt new file mode 100644 index 00000000..f456edfa --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/BewerbAnlegenViewModel.kt @@ -0,0 +1,88 @@ +package at.mocode.frontend.features.turnier.presentation + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +// Abteilungs-Typen gemäß Domain +enum class AbteilungsTyp { + SEPARATE_SIEGEREHRUNG, + ORGANISATORISCH, +} + +// Rider-Klasse für Vorschlagslogik (vereinfachtes Modell) +enum class ReiterKlasse { R1, R2_PLUS } + +data class AbteilungsInput( + val id: Int, + val label: String, + val mitLizenz: Boolean, + val reiterKlasse: ReiterKlasse, +) + +data class BewerbAnlegenState( + val isOpen: Boolean = false, + val bewerbsTyp: String = "", + val abteilungsTyp: AbteilungsTyp = AbteilungsTyp.SEPARATE_SIEGEREHRUNG, + val abteilungen: List = emptyList(), +) + +sealed interface BewerbAnlegenIntent { + data object Open : BewerbAnlegenIntent + data object Close : BewerbAnlegenIntent + data class SetBewerbsTyp(val typ: String) : BewerbAnlegenIntent + data class SetAbteilungsTyp(val typ: AbteilungsTyp) : BewerbAnlegenIntent + data object ApplyAutoSuggestionIfNeeded : BewerbAnlegenIntent +} + +class BewerbAnlegenViewModel { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _state = MutableStateFlow(BewerbAnlegenState()) + val state: StateFlow = _state + + fun send(intent: BewerbAnlegenIntent) { + when (intent) { + is BewerbAnlegenIntent.Open -> reduce { it.copy(isOpen = true) } + is BewerbAnlegenIntent.Close -> reduce { BewerbAnlegenState() } + is BewerbAnlegenIntent.SetBewerbsTyp -> reduce { it.copy(bewerbsTyp = intent.typ) }.also { + // Bei Änderung des Typs gleich prüfen, ob Auto-Vorschlag anzuwenden ist + send(BewerbAnlegenIntent.ApplyAutoSuggestionIfNeeded) + } + is BewerbAnlegenIntent.SetAbteilungsTyp -> reduce { it.copy(abteilungsTyp = intent.typ) } + is BewerbAnlegenIntent.ApplyAutoSuggestionIfNeeded -> applySuggestion() + } + } + + private fun applySuggestion() { + val s = _state.value + val bTyp = s.bewerbsTyp.uppercase() + + val suggestion = when { + bTyp.contains("CSN-C-NEU") -> listOf( + AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1), + AbteilungsInput(2, label = "Abteilung 2: R2+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS), + ) + bTyp.contains("CDN-B") || bTyp.contains("CDNP-B") -> listOf( + AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1), + AbteilungsInput(2, label = "Abteilung 2: R2", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS), + AbteilungsInput(3, label = "Abteilung 3: R3+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS), + ) + bTyp.contains("CSN-B") -> listOf( + AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1), + AbteilungsInput(2, label = "Abteilung 2: R2+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS), + ) + else -> emptyList() + } + + if (suggestion.isNotEmpty()) { + reduce { it.copy(abteilungen = suggestion, abteilungsTyp = AbteilungsTyp.SEPARATE_SIEGEREHRUNG) } + } + } + + private inline fun reduce(block: (BewerbAnlegenState) -> BewerbAnlegenState) { + _state.value = block(_state.value) + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/BewerbViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/BewerbViewModel.kt new file mode 100644 index 00000000..56829993 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/BewerbViewModel.kt @@ -0,0 +1,392 @@ +package at.mocode.frontend.features.turnier.presentation + +import at.mocode.frontend.core.network.discovery.DiscoveredService +import at.mocode.frontend.core.network.sync.DataChangedEvent +import at.mocode.frontend.core.network.sync.PingEvent +import at.mocode.frontend.core.network.sync.SyncManager +import at.mocode.frontend.features.turnier.domain.Bewerb +import at.mocode.frontend.features.turnier.domain.BewerbRepository +import at.mocode.frontend.features.turnier.domain.StartlistenRepository +import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile +import at.mocode.zns.parser.ZnsBewerb +import at.mocode.zns.parser.ZnsBewerbParser +import at.mocode.zns.parser.ZnsNennung +import at.mocode.zns.parser.ZnsNennungParser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +typealias BewerbListItem = Bewerb + +data class BewerbState( + val isLoading: Boolean = false, + val searchQuery: String = "", + val list: List = emptyList(), + val filtered: List = emptyList(), + val selectedId: Long? = null, + val errorMessage: String? = null, + val importPreview: List = emptyList(), + val nennungenPreview: List = emptyList(), + val showImportDialog: Boolean = false, + val showStartlistePreview: Boolean = false, + val currentStartliste: List = emptyList(), + val discoveredNodes: List = emptyList(), + val isScanning: Boolean = false, + // Zeitplan-Audit + val auditLog: List = emptyList(), + 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.frontend.features.turnier.domain.Ergebnis? = null, + val selectedZeile: StartlistenZeile? = null +) + +sealed interface BewerbIntent { + data object Load : BewerbIntent + data object Refresh : BewerbIntent + data class SearchChanged(val query: String) : BewerbIntent + data class Select(val id: Long?) : BewerbIntent + data object ClearError : BewerbIntent + + // Delegation an Dialog-VM + data object OpenDialog : BewerbIntent + data object CloseDialog : BewerbIntent + data class SetBewerbsTyp(val typ: String) : BewerbIntent + data class SetAbteilungsTyp(val typ: AbteilungsTyp) : BewerbIntent + + data object OpenImportDialog : BewerbIntent + data object CloseImportDialog : BewerbIntent + data class ProcessImportFile(val lines: List) : BewerbIntent + data class ConfirmImport(val turnierId: Long) : BewerbIntent + data object GenerateStartliste : BewerbIntent + data object CloseStartlistePreview : BewerbIntent + data object StartNetworkScan : BewerbIntent + data object StopNetworkScan : BewerbIntent + data object RefreshDiscoveredNodes : BewerbIntent + data class UpdateZeitplan(val id: Long, val beginnZeit: String?) : 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.frontend.features.turnier.domain.Ergebnis) : BewerbIntent + data object CalculatePlatzierung : BewerbIntent + data object ExportErgebnislistePdf : BewerbIntent +} + + +class BewerbViewModel( + private val repo: BewerbRepository, + private val startlistenRepo: StartlistenRepository, + private val ergebnisRepo: at.mocode.frontend.features.turnier.domain.ErgebnisRepository, + private val syncManager: SyncManager? = null, + private val turnierId: Long, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _state = MutableStateFlow(BewerbState(isLoading = true)) + val state: StateFlow = _state + + // Interne Instanz des Dialog‑VM (teilen State via Kopie) + private val dialogVm = BewerbAnlegenViewModel() + + init { + send(BewerbIntent.Load) + observeSyncEvents() + } + + private fun observeSyncEvents() { + syncManager?.let { manager -> + scope.launch { + manager.getIncomingEvents().collect { event -> + when (event) { + is DataChangedEvent -> { + if (event.aggregateType == "Bewerb" || event.aggregateType == "Startliste") { + load() // Bei relevanten Änderungen neu laden + } + } + + is PingEvent -> { + // Optional: Heartbeat loggen oder Status anzeigen + } + + else -> {} + } + } + } + + // Auch verbundene Peers beobachten + scope.launch { + manager.getConnectedPeers().collect { peers -> + reduce { + it.copy(discoveredNodes = peers.map { p -> + DiscoveredService("P2P", p, 0) + }) + } + } + } + } + } + + fun send(intent: BewerbIntent) { + 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) } + if (intent.id != null) { + loadErgebnisse() + } + } + + is BewerbIntent.ClearError -> reduce { it.copy(errorMessage = null) } + + is BewerbIntent.OpenDialog -> { + dialogVm.send(BewerbAnlegenIntent.Open) + syncDialogState() + } + + is BewerbIntent.CloseDialog -> { + dialogVm.send(BewerbAnlegenIntent.Close) + syncDialogState() + } + + is BewerbIntent.SetBewerbsTyp -> { + dialogVm.send(BewerbAnlegenIntent.SetBewerbsTyp(intent.typ)) + syncDialogState() + } + + is BewerbIntent.SetAbteilungsTyp -> { + dialogVm.send(BewerbAnlegenIntent.SetAbteilungsTyp(intent.typ)) + syncDialogState() + } + + is BewerbIntent.OpenImportDialog -> _state.value = _state.value.copy(showImportDialog = true) + is BewerbIntent.CloseImportDialog -> _state.value = + _state.value.copy(showImportDialog = false, importPreview = emptyList(), nennungenPreview = emptyList()) + + is BewerbIntent.ProcessImportFile -> { + val bewerbe = intent.lines.mapNotNull { ZnsBewerbParser.parse(it) } + val nennungen = intent.lines.mapNotNull { ZnsNennungParser.parse(it) } + _state.value = _state.value.copy(importPreview = bewerbe, nennungenPreview = nennungen) + } + + is BewerbIntent.ConfirmImport -> { + confirmImport() + } + + is BewerbIntent.GenerateStartliste -> generateStartliste() + is BewerbIntent.CloseStartlistePreview -> reduce { it.copy(showStartlistePreview = false) } + is BewerbIntent.StartNetworkScan -> startScan() + is BewerbIntent.StopNetworkScan -> stopScan() + is BewerbIntent.RefreshDiscoveredNodes -> refreshNodes() + is BewerbIntent.UpdateZeitplan -> updateZeitplan(intent.id, intent.beginnZeit) + 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.frontend.features.turnier.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() + } + } + } + + is BewerbIntent.CalculatePlatzierung -> { + val selectedId = state.value.selectedId ?: return + scope.launch { + ergebnisRepo.calculatePlatzierung(selectedId.toString()).onSuccess { + loadErgebnisse() + } + } + } + + is BewerbIntent.ExportErgebnislistePdf -> { + val selectedId = state.value.selectedId ?: return + scope.launch { + ergebnisRepo.exportPdf(selectedId.toString()).onSuccess { bytes -> + // In einer echten Desktop-App würde man hier einen File-Saver öffnen. + // Für den MVP loggen wir nur den Erfolg ein. + println("PDF Export erfolgreich: ${bytes.size} bytes") + } + } + } + } + } + + private fun loadErgebnisse() { + val bewerbId = state.value.selectedId ?: return + scope.launch { + ergebnisRepo.getForBewerb(bewerbId.toString()).onSuccess { list -> + reduce { it.copy(ergebnisse = list) } + } + } + } + + private fun exportZnsBSatz() { + _state.update { it.copy(isLoading = true) } + scope.launch { + repo.exportZnsBSatz(turnierId).onSuccess { content -> + _state.update { it.copy(isLoading = false, showExportDialog = true, exportContent = content) } + }.onFailure { t -> + _state.update { it.copy(isLoading = false, errorMessage = "ZNS-Export fehlgeschlagen: ${t.message}") } + } + } + } + + private fun loadAuditLog(id: Long) { + _state.update { it.copy(isAuditLoading = true) } + scope.launch { + repo.getAuditLog(id).onSuccess { log -> + _state.update { it.copy(auditLog = log, isAuditLoading = false) } + }.onFailure { t -> + _state.update { + it.copy( + isAuditLoading = false, + errorMessage = "Audit-Log konnte nicht geladen werden: ${t.message}" + ) + } + } + } + } + + private fun updateZeitplan(id: Long, beginn: String?) { + scope.launch { + repo.updateZeitplan(id, null, beginn, null).onSuccess { + load() // Neu laden, um Konsistenz zu prüfen + } + } + } + + private fun startScan() { + syncManager?.start(8080) + _state.update { it.copy(isScanning = true) } + // Nach dem Start des Servers ein ConnectivityCheck-Event Broadcasting, um Präsenz zu zeigen + syncManager?.broadcastEvent( + PingEvent( + eventId = turnierId.toString(), + sequenceNumber = 0, + originNodeId = "Client-${(1000..9999).random()}", + createdAt = 0 // In commonMain ohne Clock-Lib erst mal 0 + ) + ) + refreshNodes() + } + + private fun stopScan() { + syncManager?.stop() + _state.update { it.copy(isScanning = false) } + } + + private fun refreshNodes() { + // Da wir jetzt den SyncManager nutzen, könnten wir hier die connectedPeers anzeigen + // oder weiterhin die Entdeckten aus dem internen DiscoveryService des Managers. + // Für dieses MVP zeigen wir einfach an, dass wir scannen. + } + + fun generateStartliste() { + val selectedId = _state.value.selectedId ?: return + reduce { it.copy(isLoading = true) } + + scope.launch { + startlistenRepo.generate(selectedId).onSuccess { list -> + reduce { + it.copy( + isLoading = false, + showStartlistePreview = true, + currentStartliste = list + ) + } + }.onFailure { t -> + reduce { it.copy(isLoading = false, errorMessage = "Startlisten-Generierung fehlgeschlagen: ${t.message}") } + } + } + } + + private fun confirmImport() { + val toImport = _state.value.importPreview + if (toImport.isEmpty()) { + _state.value = _state.value.copy(showImportDialog = false) + return + } + + reduce { it.copy(isLoading = true) } + scope.launch { + val result = repo.importBewerbe(turnierId, toImport) + if (result.isSuccess) { + reduce { it.copy(showImportDialog = false, importPreview = emptyList()) } + load() + } else { + reduce { + it.copy( + isLoading = false, + errorMessage = "Import fehlgeschlagen: ${result.exceptionOrNull()?.message}" + ) + } + } + } + } + + private fun load() { + reduce { it.copy(isLoading = true, errorMessage = null) } + scope.launch { + repo.list(turnierId).onSuccess { items -> + reduce { cur -> + val filtered = filterList(items, cur.searchQuery) + cur.copy(isLoading = false, list = items, filtered = filtered) + } + }.onFailure { t -> + reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Unbekannter Fehler beim Laden") } + } + } + } + + private fun filter() { + val cur = _state.value + val filtered = filterList(cur.list, cur.searchQuery) + reduce { it.copy(filtered = filtered) } + } + + private fun filterList(list: List, query: String): List { + if (query.isBlank()) return list + val q = query.trim() + return list.filter { + it.name.contains(q, ignoreCase = true) || + it.sparte.contains(q, ignoreCase = true) || + it.klasse.contains(q, ignoreCase = true) || + it.tag.contains(q, ignoreCase = true) + } + } + + private fun syncDialogState() { + _state.value = _state.value.copy(dialogState = dialogVm.state.value) + } + + private inline fun reduce(block: (BewerbState) -> BewerbState) { + _state.value = block(_state.value) + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt new file mode 100644 index 00000000..8c0a64e1 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt @@ -0,0 +1,316 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import at.mocode.frontend.features.turnier.data.remote.CreateBewerbPayload +import at.mocode.frontend.features.turnier.data.remote.RichterEinsatzDto +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +enum class WizardStep { IDENTIFIKATION, DETAILS_FINANZEN, ORT_ZEIT, RICHTER_TEILUNG } + +data class CreateBewerbWizardState( + // Step 1 + val klasse: String = "", + val hoeheCm: String = "", // UI-Text, wird zu Int? geparst + val bezeichnung: String = "", + + // Step 2 + val beschreibung: String = "", + val aufgabe: String = "", + val startgeld: String = "", // UI-Text, wird zu Long? Cent + val geldpreisAusbezahlt: Boolean = false, + + // Step 3 + val austragungsplatzId: String = "", + val beginnZeitTyp: String = "", // FIX / ANSCHLIESSEND + val geplantesDatum: String = "", // yyyy-MM-dd + val beginnZeit: String = "", // HH:mm + val reitdauerMinuten: String = "", + val umbauMinuten: String = "", + val besichtigungMinuten: String = "", + val stechenGeplant: Boolean = false, + + // Step 4 + val richter: List = emptyList(), + val teilungsTyp: String = "", // Hinweis: aktuell nur UI; Backend-Feld folgt separat +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateBewerbWizardScreen( + modifier: Modifier = Modifier, + state: CreateBewerbWizardState, + onStateChange: (CreateBewerbWizardState) -> Unit, + onSubmit: (CreateBewerbPayload) -> Unit, +) { + var selectedTab by remember { mutableStateOf(0) } + val steps = WizardStep.entries.toTypedArray() + + Column(modifier.fillMaxSize().padding(16.dp)) { + Text("Neuen Bewerb anlegen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + + SecondaryTabRow( + selectedTabIndex = selectedTab, + modifier = Modifier, + divider = { HorizontalDivider() } + ) { + Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Identifikation") }) + Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }, text = { Text("Details & Finanzen") }) + Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }, text = { Text("Ort & Zeitplan") }) + Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }, text = { Text("Richter & Teilung") }) + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + when (steps[selectedTab]) { + WizardStep.IDENTIFIKATION -> StepIdentifikation(state, onStateChange) + WizardStep.DETAILS_FINANZEN -> StepDetailsFinanzen(state, onStateChange) + WizardStep.ORT_ZEIT -> StepOrtZeit(state, onStateChange) + WizardStep.RICHTER_TEILUNG -> StepRichterTeilung(state, onStateChange) + } + + Spacer(Modifier.height(16.dp)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(enabled = selectedTab > 0, onClick = { selectedTab-- }) { Text("Zurück") } + Spacer(Modifier.weight(1f)) + if (selectedTab < steps.lastIndex) { + TextButton(onClick = { selectedTab++ }) { Text("Weiter") } + } else { + OutlinedButton(onClick = { + val payload = state.toPayloadOrNull() + if (payload != null) onSubmit(payload) + }) { Text("Bewerb anlegen") } + } + } + } +} + +@Composable +private fun StepIdentifikation(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + item { + OutlinedTextField( + value = state.klasse, + onValueChange = { onStateChange(state.copy(klasse = it)) }, + label = { Text("Sparte/Kategorie/Klasse") }, + modifier = Modifier.fillMaxWidth() + ) + } + item { + OutlinedTextField( + value = state.hoeheCm, + onValueChange = { onStateChange(state.copy(hoeheCm = it.filter { ch -> ch.isDigit() })) }, + label = { Text("Höhe (cm)") }, + modifier = Modifier.fillMaxWidth() + ) + } + item { + OutlinedTextField( + value = state.bezeichnung, + onValueChange = { onStateChange(state.copy(bezeichnung = it)) }, + label = { Text("Bezeichnung") }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun StepDetailsFinanzen(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + item { + OutlinedTextField( + value = state.beschreibung, + onValueChange = { onStateChange(state.copy(beschreibung = it)) }, + label = { Text("Beschreibung (optional)") }, + modifier = Modifier.fillMaxWidth() + ) + } + item { + OutlinedTextField( + value = state.aufgabe, + onValueChange = { onStateChange(state.copy(aufgabe = it)) }, + label = { Text("Aufgabe (z.B. R1)") }, + modifier = Modifier.fillMaxWidth() + ) + } + item { + OutlinedTextField( + value = state.startgeld, + onValueChange = { onStateChange(state.copy(startgeld = it.filter { ch -> ch.isDigit() })) }, + label = { Text("Startgeld (Cent)") }, + modifier = Modifier.fillMaxWidth() + ) + } + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = state.geldpreisAusbezahlt, onCheckedChange = { onStateChange(state.copy(geldpreisAusbezahlt = it)) }) + Text("Geldpreis ausbezahlt") + } + } + } +} + +@Composable +private fun StepOrtZeit(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + item { + OutlinedTextField( + value = state.austragungsplatzId, + onValueChange = { onStateChange(state.copy(austragungsplatzId = it)) }, + label = { Text("Austragungsplatz-ID (optional)") }, + modifier = Modifier.fillMaxWidth() + ) + } + item { + OutlinedTextField( + value = state.beginnZeitTyp, + onValueChange = { onStateChange(state.copy(beginnZeitTyp = it)) }, + label = { Text("Beginn (FIX/ANSCHLIESSEND)") }, + modifier = Modifier.fillMaxWidth() + ) + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = state.geplantesDatum, + onValueChange = { onStateChange(state.copy(geplantesDatum = it)) }, + label = { Text("Datum (yyyy-MM-dd)") }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = state.beginnZeit, + onValueChange = { onStateChange(state.copy(beginnZeit = it)) }, + label = { Text("Beginn (HH:mm)") }, + modifier = Modifier.weight(1f) + ) + } + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = state.reitdauerMinuten, + onValueChange = { onStateChange(state.copy(reitdauerMinuten = it.filter { ch -> ch.isDigit() })) }, + label = { Text("Reitdauer (min)") }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = state.umbauMinuten, + onValueChange = { onStateChange(state.copy(umbauMinuten = it.filter { ch -> ch.isDigit() })) }, + label = { Text("Umbau (min)") }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = state.besichtigungMinuten, + onValueChange = { onStateChange(state.copy(besichtigungMinuten = it.filter { ch -> ch.isDigit() })) }, + label = { Text("Besichtigung (min)") }, + modifier = Modifier.weight(1f) + ) + } + } + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = state.stechenGeplant, onCheckedChange = { onStateChange(state.copy(stechenGeplant = it)) }) + Text("Stechen geplant") + } + } + } +} + +@Composable +private fun StepRichterTeilung(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) { + Column(Modifier.fillMaxWidth()) { + // Warn-Logik (mock): Wenn Richter ausgewählt und Position = "C" ohne weiterer Prüfung -> TB-Hinweis + val warnTb = state.richter.isNotEmpty() + if (warnTb) { + Box( + Modifier.fillMaxWidth().background(Color(0xFFFFF8E1)).padding(12.dp) + ) { Text("Hinweis: Richter-Zuweisung erfordert Freigabe durch TB (Qualifikation prüfen)", color = Color(0xFFFFA000)) } + Spacer(Modifier.height(8.dp)) + } + + OutlinedTextField( + value = state.teilungsTyp, + onValueChange = { onStateChange(state.copy(teilungsTyp = it)) }, + label = { Text("Teilungsregel (z.B. MANUELL)") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(8.dp)) + + // Minimal-UI für das Hinzufügen eines Richters (freie Eingabe von UUID + Position) + var funktionaerId by remember { mutableStateOf("") } + var position by remember { mutableStateOf("") } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField(funktionaerId, { funktionaerId = it }, label = { Text("Funktionär-ID") }, modifier = Modifier.weight(1f)) + OutlinedTextField(position, { position = it }, label = { Text("Position (C/M/…)") }, modifier = Modifier.weight(1f)) + TextButton(onClick = { + if (funktionaerId.isNotBlank() && position.isNotBlank()) { + val list = state.richter + RichterEinsatzDto(funktionaerId = funktionaerId.trim(), position = position.trim()) + onStateChange(state.copy(richter = list)) + funktionaerId = ""; position = "" + } + }) { Text("Hinzufügen") } + } + + Spacer(Modifier.height(8.dp)) + + state.richter.forEachIndexed { idx, r -> + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${idx + 1}. ${r.position} – ${r.funktionaerId}") + TextButton(onClick = { + val list = state.richter.toMutableList().also { it.removeAt(idx) } + onStateChange(state.copy(richter = list)) + }) { Text("Entfernen") } + } + } + } +} + +// --- Mapping UI-State -> API-Payload --- +private fun CreateBewerbWizardState.toPayloadOrNull(): CreateBewerbPayload? { + if (klasse.isBlank() || bezeichnung.isBlank()) return null + + val hoehe: Int? = hoeheCm.toIntOrNull() + val startgeldCent: Long? = startgeld.toLongOrNull() + + val datum: LocalDate? = runCatching { if (geplantesDatum.isBlank()) null else LocalDate.parse(geplantesDatum) }.getOrNull() + val zeit: LocalTime? = runCatching { if (beginnZeit.isBlank()) null else LocalTime.parse(beginnZeit) }.getOrNull() + val beginnTyp: String? = beginnZeitTyp.ifBlank { null } + + val reitMin = reitdauerMinuten.toIntOrNull() + val umbauMin = umbauMinuten.toIntOrNull() + val besMin = besichtigungMinuten.toIntOrNull() + + return CreateBewerbPayload( + klasse = klasse.trim(), + hoeheCm = hoehe, + bezeichnung = bezeichnung.trim(), + beschreibung = beschreibung.ifBlank { null }, + aufgabe = aufgabe.ifBlank { null }, + aufgabenNummer = null, + paraGrade = null, + austragungsplatzId = austragungsplatzId.ifBlank { null }, + richterEinsaetze = richter, + geplantesDatum = datum, + beginnZeitTyp = beginnTyp, + beginnZeit = zeit, + reitdauerMinuten = reitMin, + umbauMinuten = umbauMin, + besichtigungMinuten = besMin, + stechenGeplant = stechenGeplant, + startgeldCent = startgeldCent, + geldpreisAusbezahlt = geldpreisAusbezahlt, + ) +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/MasterdataEditDialogs.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/MasterdataEditDialogs.kt new file mode 100644 index 00000000..6a20815f --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/MasterdataEditDialogs.kt @@ -0,0 +1,212 @@ +package at.mocode.frontend.features.turnier.presentation + +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.frontend.features.turnier.domain.Pferd +import at.mocode.frontend.features.turnier.domain.Reiter +import at.mocode.frontend.features.turnier.domain.Ergebnis +import at.mocode.frontend.features.turnier.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( + reiter: Reiter, + onDismiss: () -> Unit, + onSave: (Reiter) -> Unit +) { + var vorname by remember { mutableStateOf(reiter.vorname) } + var nachname by remember { mutableStateOf(reiter.nachname) } + var oepsNummer by remember { mutableStateOf(reiter.oepsNummer ?: "") } + var verein by remember { mutableStateOf(reiter.verein ?: "") } + var feiId by remember { mutableStateOf(reiter.feiId ?: "") } + + val isVornameValid = vorname.isNotBlank() + val isNachnameValid = nachname.isNotBlank() + val isOepsValid = oepsNummer.isBlank() || oepsNummer.all { it.isDigit() || it.isLetter() } // Einfache Prüfung + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Reiter bearbeiten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = vorname, + onValueChange = { vorname = it }, + label = { Text("Vorname*") }, + isError = !isVornameValid + ) + OutlinedTextField( + value = nachname, + onValueChange = { nachname = it }, + label = { Text("Nachname*") }, + isError = !isNachnameValid + ) + OutlinedTextField( + value = oepsNummer, + onValueChange = { oepsNummer = it }, + label = { Text("OEPS-Nr.") }, + isError = !isOepsValid, + supportingText = { if(!isOepsValid) Text("Ungültiges Format") } + ) + OutlinedTextField(value = verein, onValueChange = { verein = it }, label = { Text("Verein") }) + OutlinedTextField(value = feiId, onValueChange = { feiId = it }, label = { Text("FEI-ID") }) + } + }, + confirmButton = { + Button( + onClick = { + onSave(reiter.copy( + vorname = vorname, + nachname = nachname, + oepsNummer = oepsNummer, + satznummer = oepsNummer, + verein = verein, + feiId = feiId + )) + }, + enabled = isVornameValid && isNachnameValid && isOepsValid + ) { + Text("Speichern") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} + +@Composable +fun PferdEditDialog( + pferd: Pferd, + onDismiss: () -> Unit, + onSave: (Pferd) -> Unit +) { + var name by remember { mutableStateOf(pferd.name) } + var lebensnummer by remember { mutableStateOf(pferd.lebensnummer) } + var oepsNummer by remember { mutableStateOf(pferd.oepsNummer ?: "") } + var geburtsjahr by remember { mutableStateOf(pferd.geburtsjahr?.toString() ?: "") } + + val isNameValid = name.isNotBlank() + val isLebensnrValid = lebensnummer.isNotBlank() + val isJahrValid = geburtsjahr.isBlank() || (geburtsjahr.toIntOrNull() != null && geburtsjahr.length == 4) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Pferd bearbeiten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name*") }, + isError = !isNameValid + ) + OutlinedTextField( + value = lebensnummer, + onValueChange = { lebensnummer = it }, + label = { Text("Lebensnummer*") }, + isError = !isLebensnrValid + ) + OutlinedTextField(value = oepsNummer, onValueChange = { oepsNummer = it }, label = { Text("OEPS-Nr.") }) + OutlinedTextField( + value = geburtsjahr, + onValueChange = { geburtsjahr = it }, + label = { Text("Geburtsjahr") }, + isError = !isJahrValid, + supportingText = { if(!isJahrValid) Text("4-stellige Jahreszahl") } + ) + } + }, + confirmButton = { + Button( + onClick = { + onSave(pferd.copy( + name = name, + lebensnummer = lebensnummer, + oepsNummer = oepsNummer, + geburtsjahr = geburtsjahr.toIntOrNull() + )) + }, + enabled = isNameValid && isLebensnrValid && isJahrValid + ) { + Text("Speichern") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + } + ) +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/SeriesScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/SeriesScreen.kt new file mode 100644 index 00000000..25fb6646 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/SeriesScreen.kt @@ -0,0 +1,120 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.viewmodel.koinViewModel + +private val SeriesBlue = Color(0xFF1E3A8A) + +/** + * SERIES-Screen gemäß Vision_03 & Phase 10. + */ +@Composable +fun SeriesScreen( + title: String, + onBack: () -> Unit, + viewModel: SeriesViewModel = koinViewModel() +) { + val state by viewModel.state.collectAsState() + + Column(modifier = Modifier.fillMaxSize()) { + // Toolbar + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text("Konfiguration & Auswertung (Phase 10)", fontSize = 13.sp, color = Color.Gray) + } + Button( + onClick = { viewModel.createSerie("Neuer Cup") }, + colors = ButtonDefaults.buttonColors(containerColor = SeriesBlue) + ) { + Text("Neue Serie anlegen") + } + } + + HorizontalDivider() + + if (state.series.isEmpty()) { + EmptyState(title, onBack) + } else { + SeriesList(state, onSelect = { viewModel.selectSerie(it) }) + } + } +} + +@Composable +private fun SeriesList(state: SeriesState, onSelect: (String) -> Unit) { + Row(Modifier.fillMaxSize()) { + LazyColumn(Modifier.weight(0.4f).padding(16.dp)) { + items(state.series) { serie -> + Card( + onClick = { serie.id?.let { onSelect(it) } }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) { + Column(Modifier.padding(12.dp)) { + Text(serie.name, fontWeight = FontWeight.Bold) + Text(serie.reglementTyp, fontSize = 12.sp) + } + } + } + } + VerticalDivider() + Column(Modifier.weight(0.6f).padding(16.dp)) { + Text("Zwischenstand", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + state.selectedSerieStand.forEach { entry -> + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text("Reiter ID: ${entry.reiterId}", fontWeight = FontWeight.Medium) + entry.pferdId?.let { + Text("Pferd ID: $it", fontSize = 11.sp, color = Color.Gray) + } + } + Column(horizontalAlignment = Alignment.End) { + Text("${entry.punkte} Pkt", fontWeight = FontWeight.Bold, color = SeriesBlue) + Text("${entry.anzahlWertungen} Wertungen", fontSize = 10.sp, color = Color.Gray) + } + } + HorizontalDivider(thickness = 0.5.dp, color = Color.LightGray) + } + } + } +} + +@Composable +private fun EmptyState(title: String, onBack: () -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Keine $title konfiguriert", fontSize = 16.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.height(8.dp)) + Text( + "Verknüpfe Bewerbe zu einer Serie, um Punktestände automatisch zu berechnen.", + fontSize = 13.sp, + color = Color.Gray, + modifier = Modifier.padding(horizontal = 32.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(Modifier.height(24.dp)) + OutlinedButton(onClick = onBack) { + Text("Zurück zur Verwaltung") + } + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/SeriesViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/SeriesViewModel.kt new file mode 100644 index 00000000..abd8ff61 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/SeriesViewModel.kt @@ -0,0 +1,60 @@ +package at.mocode.frontend.features.turnier.presentation + +import at.mocode.frontend.features.turnier.domain.Serie +import at.mocode.frontend.features.turnier.domain.SerieStandEntry +import at.mocode.frontend.features.turnier.domain.SeriesRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope + +data class SeriesState( + val series: List = emptyList(), + val isLoading: Boolean = false, + val selectedSerieStand: List = emptyList(), + val error: String? = null +) + +class SeriesViewModel( + private val repository: SeriesRepository +) : ViewModel() { + + private val _state = MutableStateFlow(SeriesState()) + val state = _state.asStateFlow() + + init { + loadSeries() + } + + fun loadSeries() { + viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true) + repository.getAll() + .onSuccess { series -> + _state.value = _state.value.copy(series = series, isLoading = false) + } + .onFailure { + _state.value = _state.value.copy(error = it.message, isLoading = false) + } + } + } + + fun selectSerie(id: String) { + viewModelScope.launch { + repository.getStand(id) + .onSuccess { stand -> + _state.value = _state.value.copy(selectedSerieStand = stand) + } + } + } + + fun createSerie(name: String) { + viewModelScope.launch { + repository.save(Serie(name = name)) + .onSuccess { + loadSeries() + } + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierAbrechnungTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierAbrechnungTab.kt new file mode 100644 index 00000000..69da20c8 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierAbrechnungTab.kt @@ -0,0 +1,292 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Print +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import at.mocode.frontend.core.designsystem.models.PlaceholderContent +import at.mocode.frontend.features.billing.presentation.BillingScreen +import at.mocode.frontend.features.billing.presentation.BillingViewModel +import org.koin.compose.koinInject + +private val PrimaryBlue = Color(0xFF1E3A8A) +private val AccentBlue = Color(0xFF3B82F6) +private val OffenePostenRot = Color(0xFFDC2626) + +/** + * ABRECHNUNG-Tab im TurnierDetailScreen. + * Gemäß Figma Vision_03 (figma-entwurf_06): + * - Sub-Tabs: BUCHUNGEN | OFFENE POSTEN | RECHNUNG + * - Rechte Sidebar: AUSWAHL | VERKAUF | BUCHUNGEN | ADRESSEN + * - Buchungstabelle: Buchungstext, Soll, Haben, Saldo, Buchen-Checkbox, Rechnung-Checkbox + * - Rechte Sidebar: Suche nach Reiter/Pferd, Zahlungsart, Buchen-Button + */ +@Composable +fun AbrechnungTabContent(veranstaltungId: Long) { + val billingViewModel: BillingViewModel = koinInject() + + BillingScreen( + viewModel = billingViewModel, + veranstaltungId = veranstaltungId, + onBack = {} + ) +} + +/* Alter Inhalt auskommentiert oder entfernt */ +@Composable +private fun LegacyAbrechnungTabContent() { + var subTab by remember { mutableIntStateOf(0) } + var sidebarTab by remember { mutableIntStateOf(2) } // BUCHUNGEN default + val subTabs = listOf("BUCHUNGEN", "OFFENE POSTEN", "RECHNUNG") + val sidebarTabs = listOf("AUSWAHL", "VERKAUF", "BUCHUNGEN", "ADRESSEN") + + // Placeholder-Buchungen + val buchungen = remember { + listOf( + BuchungspositionUiModel("Startgebühr Bewerb 12 - Dressur Kl. A", 25.00, 0.00), + BuchungspositionUiModel("Startgebühr Bewerb 15 - Springen Kl. B", 30.00, 0.00), + BuchungspositionUiModel("Nenngeld", 15.00, 0.00), + BuchungspositionUiModel("Box 3 Tage", 45.00, 0.00), + ) + } + + Row(modifier = Modifier.fillMaxSize()) { + // ── Hauptbereich ───────────────────────────────────────────────────── + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + // Sub-Tabs + SecondaryTabRow( + selectedTabIndex = subTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = Color(0xFF1E3A8A), + ) { + subTabs.forEachIndexed { i, title -> + Tab( + selected = subTab == i, + onClick = { subTab = i }, + text = { + Text( + title, + fontSize = 12.sp, + fontWeight = if (subTab == i) FontWeight.Bold else FontWeight.Normal + ) + }, + ) + } + } + + when (subTab) { + 0 -> BuchungenContent(buchungen) + 1 -> OffenePostenContent() + 2 -> RechnungContent() + } + } + + VerticalDivider() + + // ── Rechte Sidebar ─────────────────────────────────────────────────── + Column(modifier = Modifier.width(320.dp).fillMaxHeight()) { + SecondaryTabRow( + selectedTabIndex = sidebarTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = Color(0xFF1E3A8A), + ) { + sidebarTabs.forEachIndexed { i, title -> + Tab( + selected = sidebarTab == i, + onClick = { sidebarTab = i }, + text = { Text(title, fontSize = 11.sp) }, + ) + } + } + when (sidebarTab) { + 2 -> BuchungenSidebar() + else -> PlaceholderContent(title = sidebarTabs[sidebarTab], subtitle = "") + } + } + } +} + +@Composable +private fun BuchungenContent(buchungen: List) { + val gesamtSoll = buchungen.sumOf { it.soll } + val gesamtHaben = buchungen.sumOf { it.haben } + val gesamtSaldo = gesamtSoll - gesamtHaben + + Column(modifier = Modifier.fillMaxSize()) { + // Toolbar + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("Aktualisieren", fontSize = 12.sp) + } + OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) { + Text("Übersicht", fontSize = 12.sp) + } + OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) { + Text("Tabelle Leeren", fontSize = 12.sp, color = Color(0xFFEA580C)) + } + OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) { + Text("Pferd aus Liste entfernen", fontSize = 12.sp) + } + } + + // Tabellen-Header + Row( + modifier = Modifier.fillMaxWidth().background(Color(0xFFF3F4F6)).padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text("Buchungstext", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f)) + Text("Soll", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Haben", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Saldo", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Buchen", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp)) + Text("Rechnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp)) + } + HorizontalDivider() + + LazyColumn(modifier = Modifier.weight(1f)) { + items(buchungen) { b -> + val saldo = b.soll - b.haben + var buchen by remember { mutableStateOf(false) } + var rechnung by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(b.buchungstext, fontSize = 13.sp, modifier = Modifier.weight(3f)) + Text("%.2f €".format(b.soll), fontSize = 13.sp, modifier = Modifier.weight(1f)) + Text("%.2f €".format(b.haben), fontSize = 13.sp, modifier = Modifier.weight(1f)) + Text( + "%.2f €".format(saldo), + fontSize = 13.sp, + color = if (saldo > 0) OffenePostenRot else Color.Unspecified, + fontWeight = if (saldo > 0) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.weight(1f), + ) + Checkbox(checked = buchen, onCheckedChange = { buchen = it }, modifier = Modifier.width(60.dp)) + Checkbox(checked = rechnung, onCheckedChange = { rechnung = it }, modifier = Modifier.width(70.dp)) + } + HorizontalDivider(color = Color(0xFFE5E7EB)) + } + } + + // Gesamt-Zeile + HorizontalDivider() + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("GESAMT", fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(3f)) + Text("%.2f €".format(gesamtSoll), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Text("%.2f €".format(gesamtHaben), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Text( + "%.2f €".format(gesamtSaldo), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = if (gesamtSaldo > 0) OffenePostenRot else Color.Unspecified, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun BuchungenSidebar() { + var suchtext by remember { mutableStateOf("") } + var zahlungsart by remember { mutableStateOf("BAR") } + + Column(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Nach Reiter oder Pferd", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = suchtext, + onValueChange = { suchtext = it }, + placeholder = { Text("Bitte auswählen...", fontSize = 12.sp) }, + modifier = Modifier.fillMaxWidth().height(44.dp), + singleLine = true, + ) + + HorizontalDivider() + + Text("Buchen:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("0.00 €", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Button( + onClick = {}, + enabled = false, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + ) { Text("Buchen") } + } + + HorizontalDivider() + + Text("Direkt Drucken:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) { + Icon(Icons.Default.Print, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("Saldo", fontSize = 12.sp) + } + OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) { + Text("Rechnung", fontSize = 12.sp) + } + } + + HorizontalDivider() + + Text("Zahlungsart:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + listOf("BAR", "Scheck (+30 €)", "Bankomat", "Kreditkarte").forEach { art -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = zahlungsart == art, onClick = { zahlungsart = art }) + Text(art, fontSize = 13.sp) + } + } + Button( + onClick = {}, + enabled = false, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + ) { Text("Gebühr buchen") } + + // Hinweis + Surface( + color = Color(0xFFEFF6FF), + shape = MaterialTheme.shapes.small, + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)), + ) { + Text( + "💡 Hinweis: Bei Barzahlung werden die Buchungen sofort verarbeitet. Scheck-Zahlungen erfordern eine zusätzliche Gebühr von 30 €.", + fontSize = 11.sp, + color = Color(0xFF1E40AF), + modifier = Modifier.padding(8.dp), + ) + } + } +} + +@Composable +private fun OffenePostenContent() { + PlaceholderContent(title = "Offene Posten", subtitle = "Alle offenen Forderungen …") +} + +@Composable +private fun RechnungContent() { + PlaceholderContent(title = "Rechnung", subtitle = "Rechnungserstellung …") +} + +// --- UI-Modelle --- +data class BuchungspositionUiModel(val buchungstext: String, val soll: Double, val haben: Double) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierArtikelTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierArtikelTab.kt new file mode 100644 index 00000000..0cb6dc61 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierArtikelTab.kt @@ -0,0 +1,263 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val PrimaryBlue = Color(0xFF1E3A8A) +private val AccentBlue = Color(0xFF3B82F6) +private val DeleteRed = Color(0xFFDC2626) + +/** + * ARTIKEL-Tab im TurnierDetailScreen. + * Gemäß Figma Vision_03 (figma-entwurf_07 / figma-entwurf_08): + * - Nennungen & Gebühren: Nenngebühr, Startgebühr, Sporteuro, Nachnennungsgebühr, Nennungstausch + * - Stallungen & Boxen: Box/Tag, Einstreu, Paddock + * - Zusatzgebühren: dynamische Liste (Bezeichnung, Betrag, Pflicht) + */ +@Composable +fun ArtikelTabContent() { + var nenngebuehr by remember { mutableStateOf("0.00") } + var startgebuehr by remember { mutableStateOf("15.00") } + var sporteuro by remember { mutableStateOf("0.00") } + var nachnennungsgebuehr by remember { mutableStateOf("0.00") } + var nennungstauschGebuehr by remember { mutableStateOf("0.00") } + var boxProTag by remember { mutableStateOf("0.00") } + var einstreuErstEinstreu by remember { mutableStateOf("0.00") } + var einstreuNachlegen by remember { mutableStateOf("0.00") } + var paddockProTag by remember { mutableStateOf("0.00") } + + var zusatzgebuehren by remember { + mutableStateOf( + listOf( + ZusatzgebuehrUiModel("Stromanschluss pro Tag", "5.00", false), + ZusatzgebuehrUiModel("Camping pro Nacht", "10.00", false), + ), + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // ── Nennungen & Gebühren ───────────────────────────────────────────── + ArtikelSectionCard(title = "Nennungen & Gebühren") { + ArtikelSubSection("Nennungs- und Startgebühren") { + ArtikelFormRow("Nenngebühr pro Pferd/Reiter:", "(Grundgebühr unabhängig von Anzahl Bewerben)") { + EuroTextField(nenngebuehr) { nenngebuehr = it } + } + ArtikelFormRow("Startgebühr pro Bewerb:", "(Pro einzelner Prüfung)") { + EuroTextField(startgebuehr) { startgebuehr = it } + } + ArtikelFormRow("Sporteuro (Beitrag OEPS):", null) { + EuroTextField(sporteuro) { sporteuro = it } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + ArtikelFormRow("Nachnennungsgebühr:", "(Nach Nennschluss)") { + EuroTextField(nachnennungsgebuehr) { nachnennungsgebuehr = it } + } + ArtikelFormRow("Nennungstausch-Gebühr:", "(Pferd- oder Reiter-Wechsel)") { + EuroTextField(nennungstauschGebuehr) { nennungstauschGebuehr = it } + } + } + } + + // ── Stallungen & Boxen ─────────────────────────────────────────────── + ArtikelSectionCard(title = "Stallungen & Boxen") { + ArtikelFormRow("Box pro Tag:", null) { + EuroTextField(boxProTag) { boxProTag = it } + } + ArtikelFormRow("Einstreu (Erst-Einstreu):", null) { + EuroTextField(einstreuErstEinstreu) { einstreuErstEinstreu = it } + } + ArtikelFormRow("Einstreu (Nachlegen):", null) { + EuroTextField(einstreuNachlegen) { einstreuNachlegen = it } + } + ArtikelFormRow("Paddock pro Tag:", null) { + EuroTextField(paddockProTag) { paddockProTag = it } + } + } + + // ── Zusatzgebühren ─────────────────────────────────────────────────── + ArtikelSectionCard( + title = "Zusatzgebühren", + action = { + TextButton(onClick = { + zusatzgebuehren = zusatzgebuehren + ZusatzgebuehrUiModel("", "0.00", false) + }) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue) + Spacer(Modifier.width(4.dp)) + Text("Hinzufügen", color = AccentBlue, fontSize = 13.sp) + } + }, + ) { + // Tabellen-Header + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) { + Text("Bezeichnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f)) + Text("Betrag", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f)) + Text("Pflicht", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(44.dp)) + } + HorizontalDivider() + zusatzgebuehren.forEachIndexed { idx, z -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = z.bezeichnung, + onValueChange = { v -> + zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(bezeichnung = v) } + }, + modifier = Modifier.weight(3f).height(44.dp).padding(end = 8.dp), + singleLine = true, + ) + OutlinedTextField( + value = z.betrag, + onValueChange = { v -> + zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(betrag = v) } + }, + suffix = { Text("€") }, + modifier = Modifier.weight(1.5f).height(44.dp).padding(end = 8.dp), + singleLine = true, + ) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = z.pflicht, + onCheckedChange = { v -> + zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(pflicht = v) } + }, + ) + Text("Pflicht", fontSize = 12.sp) + } + IconButton( + onClick = { zusatzgebuehren = zusatzgebuehren.toMutableList().also { it.removeAt(idx) } }, + modifier = Modifier.size(44.dp), + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Löschen", + tint = DeleteRed, + modifier = Modifier.size(18.dp) + ) + } + } + } + } + + // ── Hinweis ────────────────────────────────────────────────────────── + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color(0xFFFFFBEB), + shape = MaterialTheme.shapes.small, + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFFDE68A)), + ) { + Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("ℹ️", fontSize = 14.sp) + Column { + Text("Hinweis zur Preisliste", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text( + "Die Gebührenstruktur wird in der offiziellen Ausschreibung veröffentlicht und ist für alle Teilnehmer verbindlich. " + + "Bei nationalen Turnieren der Kategorie C-Neu sind oft reduzierte Gebühren oder Gebührenbefreiungen üblich (z.B. kein Nenngeld, kein Sporteuro).", + fontSize = 12.sp, + color = Color(0xFF92400E), + ) + } + } + } + + // ── Aktions-Buttons ────────────────────────────────────────────────── + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + OutlinedButton(onClick = {}) { Text("Zurücksetzen") } + Spacer(Modifier.width(8.dp)) + Button(onClick = {}, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)) { Text("Speichern") } + } + } +} + +@Composable +private fun ArtikelSectionCard( + title: String, + action: @Composable (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue) + action?.invoke() + } + content() + } + } +} + +@Composable +private fun ArtikelSubSection(title: String, content: @Composable ColumnScope.() -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF9FAFB)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + content() + } + } +} + +@Composable +private fun ArtikelFormRow(label: String, hint: String?, content: @Composable RowScope.() -> Unit) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, fontSize = 13.sp, modifier = Modifier.width(220.dp), color = Color(0xFF374151)) + content() + if (hint != null) { + Spacer(Modifier.width(8.dp)) + Text( + hint, + fontSize = 11.sp, + color = Color(0xFF9CA3AF), + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + ) + } + } +} + +@Composable +private fun EuroTextField(value: String, onValueChange: (String) -> Unit) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + suffix = { Text("€") }, + modifier = Modifier.width(120.dp).height(44.dp), + singleLine = true, + ) +} + +// --- UI-Modelle --- +data class ZusatzgebuehrUiModel(val bezeichnung: String, val betrag: String, val pflicht: Boolean) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierBewerbeTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierBewerbeTab.kt new file mode 100644 index 00000000..a9f5ec34 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierBewerbeTab.kt @@ -0,0 +1,1030 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter +import kotlin.time.Duration.Companion.milliseconds + +private val PrimaryBlue = Color(0xFF1E3A8A) +private val HeaderBg = Color(0xFFF1F5F9) +private val SelectedRowBg = Color(0xFFEFF6FF) + +/** + * BEWERBE-Tab gemäß Vision_03 (Screenshots 09–12). + * + * Layout: 3-spaltig + * - Links (140dp): Aktions-Buttons (Speichern, Rückgängig, Einfügen, Löschen, Teilen, Verschieben, Startliste, Ergebnisliste) + * - Mitte (flex): Datentabelle (Tag | Platz | Bewerb | Beginn | Ende | Bewerbname | ZNS | Nennungen) + * - Rechts (340dp): Detail-Panel mit Sub-Tabs (Bewerb | Bewertung | Geldpreise | Ort/Zeit) + */ +@Composable +fun BewerbeTabContent( + viewModel: BewerbViewModel, + turnierId: Long, +) { + val state by viewModel.state.collectAsState() + + // Polling für entdeckte Dienste, wenn Scan aktiv ist + LaunchedEffect(state.isScanning) { + if (state.isScanning) { + while (true) { + viewModel.send(BewerbIntent.RefreshDiscoveredNodes) + kotlinx.coroutines.delay(2000.milliseconds) + } + } + } + + // Dialog-ViewModel für "Bewerb anlegen" + val bewerbDialogVm = remember { BewerbAnlegenViewModel() } + val bewerbDialogState by bewerbDialogVm.state.collectAsState() + + Row(modifier = Modifier.fillMaxSize()) { + // ── Linke Aktions-Spalte ────────────────────────────────────────────── + BewerbeAktionsSpalte( + modifier = Modifier.width(140.dp).fillMaxHeight(), + onBewerbEinfuegen = { bewerbDialogVm.send(BewerbAnlegenIntent.Open) }, + onZnsImport = { + val fileChooser = JFileChooser().apply { + fileFilter = FileNameExtensionFilter("ZNS Nennungs-Dateien (*.dat)", "dat") + } + val result = fileChooser.showOpenDialog(null) + if (result == JFileChooser.APPROVE_OPTION) { + val file = fileChooser.selectedFile + val lines = file.readLines(Charsets.ISO_8859_1) + viewModel.send(BewerbIntent.ProcessImportFile(lines)) + viewModel.send(BewerbIntent.OpenImportDialog) + } + }, + onGenerateStartliste = { viewModel.send(BewerbIntent.GenerateStartliste) }, + onToggleScan = { + if (state.isScanning) viewModel.send(BewerbIntent.StopNetworkScan) + else viewModel.send(BewerbIntent.StartNetworkScan) + }, + isScanning = state.isScanning + ) + VerticalDivider() + + // ── Mittlere Tabelle ────────────────────────────────────────────────── + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + // Toolbar über der Tabelle + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { viewModel.send(BewerbIntent.Refresh) }, + modifier = Modifier.height(32.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("Aktualisieren", fontSize = 12.sp) + } + Surface( + shape = MaterialTheme.shapes.small, + color = PrimaryBlue, + ) { + Text( + text = "${state.list.size} Bewerbe", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + // Suchfeld + OutlinedTextField( + value = state.searchQuery, + onValueChange = { viewModel.send(BewerbIntent.SearchChanged(it)) }, + modifier = Modifier.weight(1f).height(48.dp), + placeholder = { Text("Suche...", fontSize = 12.sp) }, + singleLine = true, + textStyle = TextStyle(fontSize = 12.sp), + ) + } + + // Tabellen-Header + BewerbeTableHeader() + HorizontalDivider() + + // Tabellen-Zeilen + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(state.filtered) { _, item -> + BewerbeTableRow( + bewerb = item.toUiModel(), + isSelected = state.selectedId == item.id, + onClick = { viewModel.send(BewerbIntent.Select(item.id)) }, + ) + HorizontalDivider(color = Color(0xFFE5E7EB)) + } + } + } + + VerticalDivider() + + // ── Rechtes Detail-Panel ────────────────────────────────────────────── + val selectedItem = state.list.find { it.id == state.selectedId } + Column(modifier = Modifier.width(340.dp).fillMaxHeight()) { + BewerbeDetailPanel( + bewerb = selectedItem?.toUiModel(), + modifier = Modifier.weight(1f), + ) + + if (state.isScanning || state.discoveredNodes.isNotEmpty()) { + HorizontalDivider() + NetworkDiscoveryPanel( + nodes = state.discoveredNodes, + isScanning = state.isScanning + ) + } + } + } + + if (bewerbDialogState.isOpen) { + BewerbAnlegenDialog( + state = bewerbDialogState, + onDismiss = { bewerbDialogVm.send(BewerbAnlegenIntent.Close) }, + onChangeTyp = { + bewerbDialogVm.send(BewerbAnlegenIntent.SetBewerbsTyp(it)) + }, + onChangeAbteilungsTyp = { + bewerbDialogVm.send(BewerbAnlegenIntent.SetAbteilungsTyp(it)) + }, + onCreate = { + // Prototyp: Noch keine Persistenz – nur schließen + bewerbDialogVm.send(BewerbAnlegenIntent.Close) + }, + ) + } + + if (state.showImportDialog) { + ZnsImportPreviewDialog( + bewerbe = state.importPreview, + nennungen = state.nennungenPreview, + onDismiss = { viewModel.send(BewerbIntent.CloseImportDialog) }, + onConfirm = { viewModel.send(BewerbIntent.ConfirmImport(turnierId)) } + ) + } + + if (state.showStartlistePreview) { + StartlistePreviewDialog( + eintraege = state.currentStartliste, + onDismiss = { viewModel.send(BewerbIntent.CloseStartlistePreview) } + ) + } +} + +@Composable +private fun StartlistePreviewDialog( + eintraege: List, + onDismiss: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.width(700.dp).heightIn(max = 600.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Startliste Vorschau", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(12.dp)) + + Box(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(1.dp)) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Row(Modifier.fillMaxWidth().background(Color(0xFFF3F4F6)).padding(8.dp)) { + Text("Nr", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Zeit", modifier = Modifier.width(60.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Reiter", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Pferd", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Wunsch", modifier = Modifier.width(80.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) + } + } + items(eintraege) { e: StartlistenZeile -> + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp)) { + Text(e.nr.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp) + Text(e.zeit, modifier = Modifier.width(60.dp), fontSize = 12.sp) + Text(e.reiter, modifier = Modifier.weight(1f), fontSize = 12.sp) + Text(e.pferd, modifier = Modifier.weight(1f), fontSize = 12.sp) + Text( + text = e.wunsch, + modifier = Modifier.width(80.dp), + fontSize = 11.sp, + color = when (e.wunsch) { + "VORNE" -> Color(0xFF059669) + "HINTEN" -> Color(0xFFDC2626) + else -> Color.Gray + } + ) + } + HorizontalDivider(color = Color.LightGray.copy(alpha = 0.3f)) + } + } + } + + Spacer(Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + OutlinedButton(onClick = onDismiss) { Text("Abbrechen") } + Spacer(Modifier.width(8.dp)) + Button(onClick = { /* TODO: Speichern Logik */ }) { Text("Startliste bestätigen") } + } + } + } + } +} + +@Composable +private fun BewerbeTableHeader() { + Row( + modifier = Modifier + .fillMaxWidth() + .background(HeaderBg) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TableHeaderCell("Tag", 90.dp) + TableHeaderCell("Platz", 50.dp) + TableHeaderCell("Bewerb", 55.dp) + TableHeaderCell("Beginn", 55.dp) + TableHeaderCell("Ende", 55.dp) + TableHeaderCell("Bewerbname", weight = 1f) + TableHeaderCell("ZNS", 45.dp) + TableHeaderCell("Nennungen", 75.dp) + } +} + +@Composable +private fun RowScope.TableHeaderCell(text: String, width: androidx.compose.ui.unit.Dp? = null, weight: Float? = null) { + val mod = when { + weight != null -> Modifier.weight(weight) + width != null -> Modifier.width(width) + else -> Modifier + } + Text( + text = text, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF374151), + modifier = mod, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BewerbeTableRow(bewerb: BewerbUiModel, isSelected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(if (isSelected) SelectedRowBg else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(bewerb.tag, fontSize = 12.sp, modifier = Modifier.width(90.dp)) + Text("${bewerb.platz}", fontSize = 12.sp, modifier = Modifier.width(50.dp)) + Text( + "${bewerb.nummer}", + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.width(55.dp), + color = if (isSelected) PrimaryBlue else Color.Unspecified + ) + Text(bewerb.beginn, fontSize = 12.sp, modifier = Modifier.width(55.dp)) + Text(bewerb.ende, fontSize = 12.sp, modifier = Modifier.width(55.dp)) + Text(bewerb.name, fontSize = 12.sp, modifier = Modifier.weight(1f), maxLines = 2) + if (bewerb.warnungen.isNotEmpty()) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + positioning = TooltipAnchorPosition.Above + ), + tooltip = { + PlainTooltip { + Column { + bewerb.warnungen.forEach { warnung -> + Text("${warnung.oetoParagraph ?: ""}: ${warnung.nachricht}", fontSize = 11.sp) + } + } + } + }, + state = rememberTooltipState(), + ) { + Icon( + Icons.Default.Warning, + contentDescription = "Warnungen vorhanden", + modifier = Modifier.padding(horizontal = 4.dp).size(16.dp), + tint = Color(0xFFFACC15) // Yellow-400 + ) + } + } else { + Spacer(Modifier.width(24.dp)) + } + Text("${bewerb.zns}", fontSize = 12.sp, modifier = Modifier.width(45.dp)) + Text("${bewerb.nennungen}", fontSize = 12.sp, modifier = Modifier.width(75.dp)) + } +} + +@Composable +private fun BewerbeAktionsSpalte( + modifier: Modifier = Modifier, + onBewerbEinfuegen: () -> Unit = {}, + onZnsImport: () -> Unit = {}, + onGenerateStartliste: () -> Unit = {}, + onToggleScan: () -> Unit = {}, + isScanning: Boolean = false, +) { + Column( + modifier = modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + AktionsBtn("Änderungen\nSpeichern") + AktionsBtn("Änderungen\nRückgängig") + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + AktionsBtn("Bewerb\nEinfügen", onClick = onBewerbEinfuegen) + AktionsBtn("ZNS Import", onClick = onZnsImport) + AktionsBtn("Bewerb\nLöschen") + AktionsBtn("Bewerb Teilen") + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + AktionsBtn("Bewerb nach\noben verschieben") + AktionsBtn("Bewerb nach\nunten verschieben") + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + AktionsBtn( + label = if (isScanning) "Netzwerk-Scan\nStoppen" else "Netzwerk-Scan\nStarten", + onClick = onToggleScan + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + AktionsBtn("Startliste\nGenerieren", onClick = onGenerateStartliste) + AktionsBtn("Startliste\nBearbeiten") + AktionsBtn("Startliste\nDrucken") + AktionsBtn("Ergebnisliste\nBearbeiten") + AktionsBtn("Ergebnisliste\nDrucken") + } +} + +@Composable +private fun NetworkDiscoveryPanel( + nodes: List, + isScanning: Boolean +) { + Column( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Netzwerk (P2P)", style = MaterialTheme.typography.titleSmall, color = PrimaryBlue) + if (isScanning) { + Spacer(Modifier.width(8.dp)) + CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 2.dp) + } + } + Spacer(Modifier.height(4.dp)) + if (nodes.isEmpty()) { + Text("Keine Instanzen gefunden", fontSize = 11.sp, color = Color.Gray) + } else { + LazyColumn { + items(nodes) { node -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp), tint = PrimaryBlue) + Spacer(Modifier.width(8.dp)) + Column { + Text(node.name, fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text("${node.host}:${node.port}", fontSize = 10.sp, color = Color.Gray) + } + } + } + } + } + } +} + +@Composable +private fun AktionsBtn(label: String, onClick: () -> Unit = {}) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth().height(48.dp), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp), + ) { + Text(label, fontSize = 11.sp, lineHeight = 13.sp) + } +} + +@Composable +private fun ZnsImportPreviewDialog( + bewerbe: List, + nennungen: List = emptyList(), + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.width(700.dp).heightIn(max = 600.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("ZNS Bewerbe & Nennungen Import", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(8.dp)) + Text( + "Gefunden: ${bewerbe.size} Bewerbe, ${nennungen.size} Nennungen", + fontSize = 14.sp, + color = Color.Gray + ) + Spacer(Modifier.height(12.dp)) + + Box(modifier = Modifier.weight(1f).background(HeaderBg).padding(2.dp)) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Row(Modifier.fillMaxWidth().background(Color.LightGray).padding(4.dp)) { + Text("Nr", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Abt", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Name", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Kl", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Kat", modifier = Modifier.width(80.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Nenn", modifier = Modifier.width(50.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp, textAlign = TextAlign.End) + } + } + itemsIndexed(bewerbe) { _, b -> + val count = nennungen.count { it.bewerbNummer == b.bewerbNummer && it.abteilung == b.abteilung } + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp)) { + Text(b.bewerbNummer.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp) + Text(b.abteilung.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp) + Text(b.name, modifier = Modifier.weight(1f), fontSize = 12.sp) + Text(b.klasse, modifier = Modifier.width(40.dp), fontSize = 12.sp) + Text(b.kategorie, modifier = Modifier.width(80.dp), fontSize = 12.sp) + Text(count.toString(), modifier = Modifier.width(50.dp), fontSize = 12.sp, textAlign = TextAlign.End, fontWeight = FontWeight.Bold) + } + HorizontalDivider(color = Color.LightGray.copy(alpha = 0.5f)) + } + } + } + + Spacer(Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + OutlinedButton(onClick = onDismiss) { Text("Abbrechen") } + Spacer(Modifier.width(8.dp)) + Button(onClick = onConfirm) { + Text("Import bestätigen") + } + } + } + } + } +} + +// Hilfs-Extension +private fun BewerbListItem.toUiModel() = BewerbUiModel( + tag = tag, + platz = platz, + nummer = 0, // In der Liste oft 0, da über ID referenziert + beginn = "", + ende = "", + name = name, + bezeichnung = "$sparte $klasse", + typ = "", + zeile1 = "", + zns = 1, + nennungen = nennungen, + warnungen = warnungen +) + +@Composable +private fun BewerbAnlegenDialog( + state: BewerbAnlegenState, + onDismiss: () -> Unit, + onChangeTyp: (String) -> Unit, + onChangeAbteilungsTyp: (AbteilungsTyp) -> Unit, + onCreate: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Bewerb anlegen") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Bewerbs-Typ + Column { + Text("Bewerbs-Typ", fontSize = 12.sp, color = Color(0xFF6B7280)) + OutlinedTextField( + value = state.bewerbsTyp, + onValueChange = onChangeTyp, + placeholder = { Text("z.B. CSN-C-NEU") }, + singleLine = true, + ) + if (state.bewerbsTyp.equals("CSN-C-NEU", ignoreCase = true)) { + AssistChip(onClick = {}, label = { Text("Pflicht-Teilung vorgeschlagen") }) + } + } + + // Abteilungs-Typ Auswahl + Column { + Text("Abteilungs-Typ", fontSize = 12.sp, color = Color(0xFF6B7280)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = state.abteilungsTyp == AbteilungsTyp.SEPARATE_SIEGEREHRUNG, + onClick = { onChangeAbteilungsTyp(AbteilungsTyp.SEPARATE_SIEGEREHRUNG) }, + label = { Text("SEPARATE_SIEGEREHRUNG") }, + ) + FilterChip( + selected = state.abteilungsTyp == AbteilungsTyp.ORGANISATORISCH, + onClick = { onChangeAbteilungsTyp(AbteilungsTyp.ORGANISATORISCH) }, + label = { Text("ORGANISATORISCH") }, + ) + } + } + + // Abteilungen (Vorschlag / Liste) + Column { + Text("Abteilungen", fontSize = 12.sp, color = Color(0xFF6B7280)) + if (state.abteilungen.isEmpty()) { + Text("Noch keine Abteilungen. Wähle einen Typ (z.B. CSN-C-NEU) für Vorschlag.", fontSize = 12.sp) + } else { + state.abteilungen.forEach { a -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(a.label, fontSize = 13.sp) + val lizenz = if (a.mitLizenz) "mit Lizenz" else "ohne Lizenz" + Text("${lizenz} · ${if (a.reiterKlasse == ReiterKlasse.R1) "R1" else "R2+"}", fontSize = 12.sp, color = Color(0xFF6B7280)) + } + } + } + } + } + }, + confirmButton = { + Button(onClick = onCreate, enabled = state.bewerbsTyp.isNotBlank()) { Text("Anlegen") } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { Text("Abbrechen") } + }, + ) +} + +@Composable +private fun BewerbeDetailPanel(bewerb: BewerbUiModel?, modifier: Modifier = Modifier) { + var subTab by remember { mutableIntStateOf(0) } + val subTabs = listOf("Bewerb", "Bewertung", "Geldpreise", "Ort/Zeit") + + Column(modifier = modifier) { + PrimaryTabRow( + selectedTabIndex = subTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = PrimaryBlue, + ) { + subTabs.forEachIndexed { i, title -> + Tab( + selected = subTab == i, + onClick = { subTab = i }, + text = { Text(title, fontSize = 12.sp) }, + ) + } + } + HorizontalDivider() + + Box(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { + when (subTab) { + 0 -> BewerbSubTab(bewerb) + 1 -> BewertungSubTab(bewerb) + 2 -> GeldpreiseSubTab() + 3 -> OrtZeitSubTab(bewerb) + } + } + } +} + +// ── Sub-Tab: Bewerb ─────────────────────────────────────────────────────────── + +@Composable +private fun BewerbSubTab(bewerb: BewerbUiModel?) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + DetailField("Nummer:", bewerb?.nummer?.toString() ?: "") + DetailField("Abteilung:", "") + DetailField("Typ:", bewerb?.typ ?: "") + DetailField("Name:", bewerb?.name ?: "") + DetailField("Bezeichnung:", bewerb?.bezeichnung ?: "") + DetailDropdown("Kategorie:") + DetailDropdown("Klasse:") + DetailDropdown("Lizenz:") + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Maximal:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) + OutlinedTextField( + value = "3", + onValueChange = {}, + modifier = Modifier.width(60.dp), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), + ) + Spacer(Modifier.width(8.dp)) + Text("Pferde je Reiter", fontSize = 12.sp, color = Color(0xFF6B7280)) + } + DetailDropdown("Pferdealter:") + DetailField("Zeile 1:", bewerb?.zeile1 ?: "") + DetailField("Zeile 2:", "") + DetailField("Zeile 3:", "") + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Logo Bewerb:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) + OutlinedTextField( + value = "", + onValueChange = {}, + modifier = Modifier.weight(1f), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), + ) + Spacer(Modifier.width(4.dp)) + OutlinedButton( + onClick = {}, + modifier = Modifier.height(40.dp), + contentPadding = PaddingValues(horizontal = 8.dp) + ) { + Text("…", fontSize = 13.sp) + } + } + } +} + +// ── Sub-Tab: Bewertung ──────────────────────────────────────────────────────── + +@Composable +private fun BewertungSubTab(bewerb: BewerbUiModel?) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Bewertungs-Konfiguration", fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = Color(0xFF374151)) + DetailField("Prüfung:", "Dressurreiterprüfung") + DetailField("Richtverfahren:", "A") + DetailField("Para-Grade:", "") + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Richteranzahl:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) + OutlinedTextField( + value = "2", + onValueChange = {}, + modifier = Modifier.width(60.dp), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), + ) + } + DetailField("Aufgabe:", "Aufgabe R") + DetailField("Aufgabennummer:", "") + DetailField("Maximalpunkte:", "") + HorizontalDivider() + Text("Richter", fontSize = 12.sp, color = Color(0xFF6B7280)) + RichterRow("C:", "Schuster Alexandra") + RichterRow("C:", "Vankova Kamila (CZ)") + } +} + +@Composable +private fun RichterRow(position: String, name: String) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(position, fontSize = 13.sp, modifier = Modifier.width(30.dp)) + OutlinedTextField( + value = name, + onValueChange = {}, + modifier = Modifier.weight(1f), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), + ) + Checkbox(checked = true, onCheckedChange = {}) + } +} + +// ── Sub-Tab: Geldpreise ─────────────────────────────────────────────────────── + +@Composable +private fun GeldpreiseSubTab() { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Geldpreis-Sektion + Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Geldpreis", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = false, onCheckedChange = {}) + Text("Geldpreis", fontSize = 13.sp) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Startgeld:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) + OutlinedTextField( + value = "15,00", + onValueChange = {}, + modifier = Modifier.width(100.dp), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Auszahlung:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) + DetailDropdown("fortführend", modifier = Modifier.weight(1f)) + } + } + } + // Geldpreis für Kadererreiter + Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Geldpreis für Kadererreiter", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = false, onCheckedChange = {}) + Text("Geldpreis für Kadererreiter", fontSize = 13.sp) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Startgeld für Kadererreiter:", fontSize = 13.sp, modifier = Modifier.width(180.dp)) + OutlinedTextField( + value = "15,00", + onValueChange = {}, + modifier = Modifier.width(100.dp), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), + ) + } + } + } + // Geldpreisvorlage + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Geldpreisvorlage wählen:", fontSize = 13.sp, modifier = Modifier.width(180.dp)) + DetailDropdown("", modifier = Modifier.weight(1f)) + } + // Tabelle + Text("0 Geldpreise", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Row( + modifier = Modifier.fillMaxWidth().background(HeaderBg).padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Text("Nummer", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Geldpreis", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + } + HorizontalDivider() + Text("•", fontSize = 12.sp, color = Color(0xFF9CA3AF), modifier = Modifier.padding(8.dp)) + } +} + +// ── Sub-Tab: Ort/Zeit ───────────────────────────────────────────────────────── + +@Composable +private fun OrtZeitSubTab(bewerb: BewerbUiModel?) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Tag:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) + DetailDropdown(bewerb?.tag ?: "28.05.2023", modifier = Modifier.weight(1f)) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Beginnzeit:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) + DetailDropdown("fix um", modifier = Modifier.width(100.dp)) + } + LabeledTimeField("Beginnzeit:", bewerb?.beginn ?: "08:00", "(hh:mm)") + LabeledTimeField("Reitdauer:", "02:00", "(mm:ss)") + LabeledTimeField("Umbau:", "10", "(mm)") + LabeledTimeField("Besichtigung:", "10", "(mm)") + LabeledTimeField("Stechen:", "", "(mm)") + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Platz:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) + DetailDropdown("Vorderer Turnierplatz", modifier = Modifier.weight(1f)) + } + } +} + +@Composable +private fun LabeledTimeField(label: String, value: String, unit: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, fontSize = 13.sp, modifier = Modifier.width(130.dp)) + OutlinedTextField( + value = value, + onValueChange = {}, + modifier = Modifier.width(80.dp), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), + ) + Spacer(Modifier.width(8.dp)) + Text(unit, fontSize = 12.sp, color = Color(0xFF6B7280)) + } +} + +// ── Hilfs-Composables ───────────────────────────────────────────────────────── + +@Composable +private fun DetailField(label: String, value: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, fontSize = 13.sp, modifier = Modifier.width(130.dp)) + OutlinedTextField( + value = value, + onValueChange = {}, + modifier = Modifier.weight(1f), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), + ) + } +} + +@Composable +private fun DetailDropdown(placeholder: String, modifier: Modifier = Modifier) { + OutlinedTextField( + value = placeholder, + onValueChange = {}, + modifier = modifier, + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), + trailingIcon = { + Text("▼", fontSize = 10.sp, color = Color(0xFF6B7280)) + }, + ) +} + +// ── UI-Modell ───────────────────────────────────────────────────────────────── + +data class BewerbUiModel( + val tag: String, + val platz: Int, + val nummer: Int, + val beginn: String, + val ende: String, + val name: String, + val bezeichnung: String, + val typ: String, + val zeile1: String, + val zns: Int, + val nennungen: Int, + val warnungen: List = emptyList(), +) + +private fun sampleBewerbe() = listOf( + BewerbUiModel( + "28.05.2023", + 1, + 1, + "08:00", + "08:00", + "Dressurreiterprüfung Reiterpass\n(Aufgabe R 1)\nPony Einsteiger Cup OO", + "Dressurreiterprüfung Reiterpass", + "Dressur", + "Pony Einsteiger Cup OO", + 0, + 0, + emptyList() + ), + BewerbUiModel( + "28.05.2023", + 1, + 2, + "08:20", + "08:20", + "Dressurreiterprüfung Reitenadel\n(Aufgabe R 4)\nPony Einsteiger Cup OO", + "Dressurreiterprüfung Reitenadel", + "Dressur", + "Pony Einsteiger Cup OO", + 0, + 0, + emptyList() + ), + BewerbUiModel( + "28.05.2023", + 1, + 3, + "08:40", + "08:40", + "Dressurreiterprüfung lsf. (Istzfrei)\n(Aufgabe LF 1)", + "Dressurreiterprüfung lsf.", + "Dressur", + "", + 0, + 0 + ), + BewerbUiModel( + "28.05.2023", + 1, + 4, + "09:00", + "09:00", + "Dressurreiterprüfung lsf. (Lizenzfrei)\n(Aufgabe LF 3)", + "Dressurreiterprüfung lsf.", + "Dressur", + "", + 0, + 0 + ), + BewerbUiModel( + "28.05.2023", + 1, + 5, + "09:20", + "09:20", + "Führzügelklasse\nOO Kids Cup", + "Führzügelklasse", + "Dressur", + "OO Kids Cup", + 0, + 0 + ), + BewerbUiModel( + "28.05.2023", + 1, + 6, + "09:40", + "09:40", + "First Ridden\nOO Kids Cup", + "First Ridden", + "Dressur", + "OO Kids Cup", + 0, + 0 + ), + BewerbUiModel( + "28.05.2023", + 1, + 7, + "10:00", + "10:00", + "Pony Dressurprüfung Kl. A (Aufgabe P 1)", + "Pony Dressurprüfung Kl. A", + "Dressur", + "", + 0, + 0 + ), + BewerbUiModel( + "28.05.2023", + 1, + 8, + "10:20", + "10:20", + "Dressurreiterprüfung Kl. A (Aufgabe DRA 1)", + "Dressurreiterprüfung Kl. A", + "Dressur", + "", + 0, + 0 + ), + BewerbUiModel( + "28.05.2023", + 1, + 9, + "10:40", + "10:40", + "Dressurreiterprüfung Kl. A (Aufgabe A 5)", + "Dressurreiterprüfung Kl. A", + "Dressur", + "", + 0, + 0 + ), + BewerbUiModel( + "28.05.2023", + 1, + 10, + "11:00", + "11:00", + "Pony Dressurprüfung Kl. A (Aufgabe P 9)", + "Pony Dressurprüfung Kl. A", + "Dressur", + "", + 0, + 0 + ), + BewerbUiModel( + "28.05.2023", + 1, + 11, + "11:20", + "11:20", + "Dressurreiterprüfung Kl. L (Aufgabe DRL 1)", + "Dressurreiterprüfung Kl. L", + "Dressur", + "", + 0, + 0 + ), + BewerbUiModel( + "28.05.2023", + 1, + 12, + "11:40", + "11:40", + "Dressurprüfung Kl. L (Aufgabe L 3)", + "Dressurprüfung Kl. L", + "Dressur", + "", + 0, + 0 + ), +) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierDetailScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierDetailScreen.kt new file mode 100644 index 00000000..f12c6c92 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierDetailScreen.kt @@ -0,0 +1,137 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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. + * + * Layout: Horizontale Tab-Bar mit 8 Tabs (kein eigener Toolbar-Zurück-Button – + * Navigation erfolgt über den Breadcrumb in der TopBar). + * + * Tabs: + * 1. STAMMDATEN – Turnier-Konfiguration, ZNS-Import, Sparten, Datum + * 2. ORGANISATION – Funktionäre, Richterkollegium, Austragungsplätze + * 3. BEWERBE – 3-spaltiges Layout (Aktionen | Tabelle | Detail-Panel) + * 4. ARTIKEL – Gebühren, Stallungen & Boxen, Zusatzgebühren + * 5. ABRECHNUNG – Buchungen, Offene Posten, Rechnung + * 6. NENNUNGEN – Pferd+Reiter-Suche, Verkauf/Buchungen, Bewerbsübersicht + * 7. STARTLISTEN – Bewerbs-Tabs, Sortierung, Zeit/Dauer + * 8. ERGEBNISLISTEN – Bewerbs-Tabs, Platzierung & Geldpreise + * + */ +@Composable +fun TurnierDetailScreen( + veranstaltungId: Long, + turnierId: Long, + onBack: () -> Unit, + eventVon: String? = null, + eventBis: String? = null, + eventOrt: String? = null, + veranstalterName: String? = null, + veranstalterOrt: String? = null, + veranstalterBundesland: String? = null, + veranstalterLogoUrl: String? = null, +) { + var selectedTab by remember { mutableIntStateOf(0) } + + // Temporäre Lösung bis zur echten Repository-Anbindung: + // Da TurnierDetailScreen in einem anderen Modul liegt, übergeben wir + // die Veranstaltungsinformationen eigentlich via ViewModel. + // Hier nutzen wir vorerst koin oder Parameter. + + val tabs = listOf( + "STAMMDATEN", + "ORGANISATION", + "BEWERBE", + "ARTIKEL", + "ABRECHNUNG", + "NENNUNGEN", + "ONLINE-EINGANG", + "ZEITPLAN", + "STARTLISTEN", + "ERGEBNISLISTEN", + ) + + val bewerbViewModel: BewerbViewModel = koinInject { parametersOf(turnierId) } + + Column(modifier = Modifier.fillMaxSize()) { + // Horizontale Tab-Bar (direkt unter der TopBar) + PrimaryScrollableTabRow( + selectedTabIndex = selectedTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = Color(0xFF1E3A8A), + edgePadding = 0.dp, + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { + Text( + text = title, + fontSize = 13.sp, + fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal, + ) + }, + ) + } + } + + HorizontalDivider() + + // Tab-Inhalte + Box(modifier = Modifier.fillMaxSize()) { + when (selectedTab) { + 0 -> StammdatenTabContent( + turnierId = turnierId, + eventVon = eventVon, + eventBis = eventBis, + eventOrt = eventOrt, + veranstalterName = veranstalterName, + veranstalterOrt = veranstalterOrt, + veranstalterBundesland = veranstalterBundesland, + veranstalterLogoUrl = veranstalterLogoUrl, + ) + 1 -> { + val nennungViewModel = koinInject(parameters = { parametersOf(turnierId) }) + OrganisationTabContent(viewModel = nennungViewModel) + } + 2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId) + 3 -> ArtikelTabContent() + 4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId) + 5 -> { + val nennungViewModel = koinInject(parameters = { parametersOf(turnierId) }) + NennungenTabContent( + viewModel = nennungViewModel, + onAbrechnungClick = { selectedTab = 4 } + ) + } + 6 -> { + val nennungViewModel = koinInject(parameters = { parametersOf(turnierId) }) + OnlineNennungEingangTabContent(turnierNr = turnierId.toString(), viewModel = nennungViewModel) + } + 7 -> ZeitplanTabContent(turnierId = turnierId, viewModel = bewerbViewModel) + 8 -> StartlistenTabContent() + 9 -> ErgebnislistenTabContent() + } + } + } +} + +// Tab-Inhalte werden in dedizierten Dateien implementiert: +// 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/frontend/features/turnier/presentation/TurnierErgebnislistenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierErgebnislistenTab.kt new file mode 100644 index 00000000..95ea126c --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierErgebnislistenTab.kt @@ -0,0 +1,269 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import at.mocode.frontend.features.turnier.domain.Ergebnis +import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile +import org.koin.compose.koinInject + +private val ElBlue = Color(0xFF1E3A8A) +private val ElHeaderBg = Color(0xFFF1F5F9) + +/** + * ERGEBNISLISTEN-Tab gemäß Vision_03. + * + * Layout: 2-spaltig + * - Links (flex): Bewerbs-Tabs + Ergebnis-Tabelle (Platz | Startnr | Pferd | Reiter | Fehler | Zeit | Punkte) + * - Rechts (280dp): Platzierung & Geldpreis-Panel + */ +@Composable +fun ErgebnislistenTabContent( + viewModel: BewerbViewModel = koinInject() +) { + val state by viewModel.state.collectAsState() + + Row(modifier = Modifier.fillMaxSize()) { + // ── Linke Spalte: Bewerbs-Tabs + Tabelle ───────────────────────────── + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + ErgebnislistenBewerbsTabs( + bewerbe = state.list, + selectedId = state.selectedId, + onSelect = { viewModel.send(BewerbIntent.Select(it)) }, + ergebnisse = state.ergebnisse, + startliste = state.currentStartliste, + onCalculate = { viewModel.send(BewerbIntent.CalculatePlatzierung) }, + onPrint = { viewModel.send(BewerbIntent.ExportErgebnislistePdf) } + ) + } + + VerticalDivider() + + // ── Rechte Spalte: Platzierung & Geldpreis ─────────────────────────── + PlatzierungGeldpreisPanel( + modifier = Modifier.width(280.dp).fillMaxHeight(), + ergebnisse = state.ergebnisse + ) + } +} + +@Composable +private fun ErgebnislistenBewerbsTabs( + bewerbe: List, + selectedId: Long?, + onSelect: (Long?) -> Unit, + ergebnisse: List, + startliste: List, + onCalculate: () -> Unit, + onPrint: () -> Unit +) { + val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0) + + PrimaryScrollableTabRow( + selectedTabIndex = selectedIndex, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = ElBlue, + edgePadding = 0.dp, + ) { + bewerbe.forEachIndexed { index, bewerb -> + Tab( + selected = selectedId == bewerb.id, + onClick = { onSelect(bewerb.id) }, + text = { Text(bewerb.tag, fontSize = 12.sp) }, + ) + } + } + HorizontalDivider() + + val selectedBewerb = bewerbe.getOrNull(selectedIndex) + + // Toolbar + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = selectedBewerb?.let { "${it.tag} – ${it.name}" } ?: "Kein Bewerb ausgewählt", + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ) + Spacer(Modifier.weight(1f)) + OutlinedButton( + onClick = onCalculate, + modifier = Modifier.height(32.dp), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Text("Platzierung berechnen", fontSize = 12.sp) + } + OutlinedButton( + onClick = {}, + modifier = Modifier.height(32.dp), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Text("Exportieren", fontSize = 12.sp) + } + OutlinedButton( + onClick = onPrint, + modifier = Modifier.height(32.dp), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Text("Drucken", fontSize = 12.sp) + } + } + + // Tabellen-Header + Row( + modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp)) + Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(65.dp)) + Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Fehler", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp)) + Text("Zeit", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp)) + Text("Punkte", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp)) + } + HorizontalDivider() + + 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)) + } + } + } +} + +@Composable +private fun PlatzierungGeldpreisPanel( + modifier: Modifier = Modifier, + ergebnisse: List = emptyList() +) { + val platzierteCount = ergebnisse.count { it.platzierung != null } + Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Platzierung & Geldpreis", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + HorizontalDivider() + + // Anzahl Platzierte + Text("Platzierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Anzahl Platzierte:", fontSize = 12.sp, modifier = Modifier.width(140.dp)) + OutlinedTextField( + value = platzierteCount.toString(), + onValueChange = {}, + modifier = Modifier.width(60.dp), + readOnly = true, + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 12.sp), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Stechen ab Platz:", fontSize = 12.sp, modifier = Modifier.width(140.dp)) + OutlinedTextField( + value = "", + onValueChange = {}, + modifier = Modifier.width(60.dp), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 12.sp), + ) + } + + HorizontalDivider() + + // Geldpreise + Text("Geldpreise", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium) + Row( + modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp)) + Text("Betrag (€)", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + } + HorizontalDivider() + listOf(1 to "–", 2 to "–", 3 to "–").forEach { (platz, betrag) -> + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "$platz.", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = ElBlue, + modifier = Modifier.width(50.dp) + ) + OutlinedTextField( + value = betrag, + onValueChange = {}, + modifier = Modifier.weight(1f).height(36.dp), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 12.sp), + ) + } + HorizontalDivider(color = Color(0xFFE5E7EB)) + } + + HorizontalDivider() + + // Aktions-Buttons + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Button( + onClick = {}, + colors = ButtonDefaults.buttonColors(containerColor = ElBlue), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Platzierung berechnen", fontSize = 12.sp) + } + OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { + Text("Ergebnisliste drucken", fontSize = 12.sp) + } + OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { + Text("Geldpreise auszahlen", fontSize = 12.sp) + } + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierNennungViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierNennungViewModel.kt new file mode 100644 index 00000000..3bf7f492 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierNennungViewModel.kt @@ -0,0 +1,163 @@ +package at.mocode.frontend.features.turnier.presentation + +import at.mocode.frontend.features.turnier.data.remote.dto.NennungEinreichenRequest +import at.mocode.frontend.features.turnier.domain.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +// --- Mock-Modelle für Online-Nennungen innerhalb dieses Moduls --- +data class OnlineNennung( + val id: String, + val vorname: String, + val nachname: String, + val lizenz: String, + val pferdName: String, + val pferdAlter: String, + val email: String, + val bewerbe: String +) + +data class TurnierOnlineUiState( + val onlineNennungen: List = emptyList(), + val isOnlineLoading: Boolean = false +) + +data class NennungenState( + val isLoading: Boolean = false, + val nennungen: List = emptyList(), + val searchResultsReiter: List = emptyList(), + val searchResultsPferde: List = emptyList(), + val searchResultsFunktionaere: List = emptyList(), + val selectedReiter: Reiter? = null, + val selectedPferd: Pferd? = null, + val errorMessage: String? = null +) + +class TurnierNennungViewModel( + private val nennungRepo: NennungRepository, + private val masterdataRepo: MasterdataRepository, + private val turnierId: Long +) { + // UI-State für den Online-Eingang Tab + val uiState = MutableStateFlow(TurnierOnlineUiState()) + + fun loadOnlineNennungen() { + uiState.value = uiState.value.copy(isOnlineLoading = true) + scope.launch { + // Mock-Laden + kotlinx.coroutines.delay(500) + uiState.value = uiState.value.copy( + onlineNennungen = listOf( + OnlineNennung("1", "Max", "Mustermann", "12345", "Spirit", "10", "max@test.at", "1, 2, 5") + ), + isOnlineLoading = false + ) + } + } + + fun uebernehmeOnlineNennung(nennung: OnlineNennung) { + // Logik zur Übernahme + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _state = MutableStateFlow(NennungenState()) + val state: StateFlow = _state + + init { + loadNennungen() + } + + fun loadNennungen() { + _state.value = _state.value.copy(isLoading = true) + scope.launch { + nennungRepo.list(turnierId).onSuccess { list -> + _state.value = _state.value.copy(nennungen = list, isLoading = false) + }.onFailure { + _state.value = _state.value.copy(errorMessage = "Fehler beim Laden: ${it.message}", isLoading = false) + } + } + } + + fun searchReiter(query: String) { + if (query.length < 2) { + _state.value = _state.value.copy(searchResultsReiter = emptyList()) + return + } + scope.launch { + masterdataRepo.searchReiter(query).onSuccess { list -> + _state.value = _state.value.copy(searchResultsReiter = list) + } + } + } + + fun selectReiter(reiter: Reiter?) { + _state.value = _state.value.copy(selectedReiter = reiter) + } + + fun saveReiter(reiter: Reiter) { + scope.launch { + masterdataRepo.saveReiter(reiter).onSuccess { + _state.value = _state.value.copy(selectedReiter = null) + } + } + } + + fun searchPferde(query: String) { + if (query.length < 2) { + _state.value = _state.value.copy(searchResultsPferde = emptyList()) + return + } + scope.launch { + masterdataRepo.searchPferde(query).onSuccess { list -> + _state.value = _state.value.copy(searchResultsPferde = list) + } + } + } + + fun selectPferd(pferd: Pferd?) { + _state.value = _state.value.copy(selectedPferd = pferd) + } + + fun savePferd(pferd: Pferd) { + scope.launch { + masterdataRepo.savePferd(pferd).onSuccess { + _state.value = _state.value.copy(selectedPferd = null) + } + } + } + + fun searchFunktionaere(query: String) { + if (query.length < 2) { + _state.value = _state.value.copy(searchResultsFunktionaere = emptyList()) + return + } + scope.launch { + masterdataRepo.searchFunktionaere(query).onSuccess { list -> + _state.value = _state.value.copy(searchResultsFunktionaere = list) + } + } + } + + fun einreichen(bewerbId: String, abteilungId: String, reiterId: String, pferdId: String) { + _state.value = _state.value.copy(isLoading = true) + scope.launch { + val request = NennungEinreichenRequest( + abteilungId = abteilungId, + bewerbId = bewerbId, + turnierId = turnierId.toString(), + reiterId = reiterId, + pferdId = pferdId + ) + nennungRepo.einreichen(request).onSuccess { + loadNennungen() + }.onFailure { + _state.value = _state.value.copy(errorMessage = "Fehler beim Einreichen: ${it.message}", isLoading = false) + } + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierNennungenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierNennungenTab.kt new file mode 100644 index 00000000..b9699f40 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierNennungenTab.kt @@ -0,0 +1,289 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val NennBlue = Color(0xFF1E3A8A) +private val NennHeaderBg = Color(0xFFF1F5F9) +private val NennSelectedBg = Color(0xFFEFF6FF) + +/** + * NENNUNGEN-Tab gemäß Vision_03. + * + * Layout: 2-spaltig + * - Links (flex): Pferd+Reiter-Suche + Nennungs-Tabelle + * - Rechts (360dp): Verkauf/Buchungen + Bewerbsübersicht + */ +@Composable +fun NennungenTabContent( + viewModel: TurnierNennungViewModel, + onAbrechnungClick: () -> Unit = {} +) { + val state by viewModel.state.collectAsState() + + // --- Editoren --- + state.selectedReiter?.let { reiter -> + ReiterEditDialog( + reiter = reiter, + onDismiss = { viewModel.selectReiter(null) }, + onSave = { viewModel.saveReiter(it) } + ) + } + state.selectedPferd?.let { pferd -> + PferdEditDialog( + pferd = pferd, + onDismiss = { viewModel.selectPferd(null) }, + onSave = { viewModel.savePferd(it) } + ) + } + + Row(modifier = Modifier.fillMaxSize()) { + // ── Linke Spalte: Suche + Tabelle ───────────────────────────────────── + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + NennungenSuchePanel(viewModel) + HorizontalDivider() + NennungenTabelle(viewModel, state) + } + + VerticalDivider() + + // ── Rechte Spalte: Verkauf + Bewerbsübersicht ───────────────────────── + Column( + modifier = Modifier + .width(360.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + ) { + VerkaufBuchungenPanel(onAbrechnungClick) + HorizontalDivider() + BewerbsuebersichtPanel() + } + } +} + +@Composable +private fun NennungenSuchePanel(viewModel: TurnierNennungViewModel) { + var pferdQuery by remember { mutableStateOf("") } + var reiterQuery by remember { mutableStateOf("") } + + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Pferd & Reiter suchen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = pferdQuery, + onValueChange = { + pferdQuery = it + viewModel.searchPferde(it) + }, + placeholder = { Text("Pferd suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) }, + modifier = Modifier.weight(1f).height(44.dp), + singleLine = true, + ) + OutlinedTextField( + value = reiterQuery, + onValueChange = { + reiterQuery = it + viewModel.searchReiter(it) + }, + placeholder = { Text("Reiter suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) }, + modifier = Modifier.weight(1f).height(44.dp), + singleLine = true, + ) + Button( + onClick = { /* In einem echten Dialog würde hier die Auswahl kombiniert */ }, + colors = ButtonDefaults.buttonColors(containerColor = NennBlue), + modifier = Modifier.height(44.dp), + ) { + Text("Nennen", fontSize = 12.sp) + } + } + } +} + +@Composable +private fun NennungenTabelle(viewModel: TurnierNennungViewModel, state: NennungenState) { + var selectedIndex by remember { mutableIntStateOf(-1) } + + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .background(NennHeaderBg) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text("ID", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp)) + Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Status", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp)) + } + HorizontalDivider() + + if (state.isLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + if (state.nennungen.isEmpty() && !state.isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Keine Nennungen vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280)) + Spacer(Modifier.height(8.dp)) + Text( + "Suchen Sie nach Pferd und Reiter, um eine EntryManagement hinzuzufügen.", + fontSize = 12.sp, + color = Color(0xFF9CA3AF) + ) + } + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(state.nennungen) { index, nennung -> + Row( + modifier = Modifier + .fillMaxWidth() + .background(if (index == selectedIndex) NennSelectedBg else Color.Transparent) + .clickable { selectedIndex = index } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + nennung.id.takeLast(6), + fontSize = 12.sp, + modifier = Modifier.width(60.dp), + color = NennBlue, + fontWeight = FontWeight.Bold + ) + Text(nennung.pferdId.takeLast(8), fontSize = 12.sp, modifier = Modifier.weight(1f)) + Text(nennung.reiterId.takeLast(8), fontSize = 12.sp, modifier = Modifier.weight(1f)) + NennungStatusBadge(nennung.status) + } + HorizontalDivider(color = Color(0xFFE5E7EB)) + } + } + } + } +} + +@Composable +private fun NennungStatusBadge(status: String) { + val (bg, fg) = when (status) { + "Gemeldet" -> Color(0xFFDCFCE7) to Color(0xFF16A34A) + "Bezahlt" -> Color(0xFFDBEAFE) to NennBlue + "Abgemeldet" -> Color(0xFFFEE2E2) to Color(0xFFDC2626) + else -> Color(0xFFF3F4F6) to Color(0xFF6B7280) + } + Surface(shape = MaterialTheme.shapes.small, color = bg) { + Text( + text = status, + fontSize = 10.sp, + color = fg, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + +@Composable +private fun VerkaufBuchungenPanel(onAbrechnungClick: () -> Unit = {}) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Verkauf / Buchungen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + TextButton(onClick = onAbrechnungClick) { + Text("Zur Abrechnung", fontSize = 11.sp, color = NennBlue) + } + } + + // Artikel-Buchungen + Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Artikel-Buchungen", fontSize = 12.sp, fontWeight = FontWeight.Medium, color = Color(0xFF374151)) + Row( + modifier = Modifier.fillMaxWidth().background(NennHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text("Artikel", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Menge", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp)) + Text("Preis", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp)) + } + HorizontalDivider() + Text( + "Keine Buchungen", + fontSize = 12.sp, + color = Color(0xFF9CA3AF), + modifier = Modifier.padding(vertical = 8.dp) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + OutlinedButton( + onClick = {}, + modifier = Modifier.height(32.dp), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Text("+ Artikel buchen", fontSize = 11.sp) + } + } + } + } + } +} + +@Composable +private fun BewerbsuebersichtPanel() { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Bewerbsübersicht", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth().background(NennHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text("Bewerb", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Nennungen", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp)) + } + HorizontalDivider() + listOf( + "Bewerb 1 – Dressur Kl. A" to 0, + "Bewerb 2 – Dressur Kl. L" to 0, + "Bewerb 3 – Springen Kl. A" to 0, + ).forEach { (name, count) -> + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(name, fontSize = 12.sp, modifier = Modifier.weight(1f)) + Text("$count", fontSize = 12.sp, modifier = Modifier.width(80.dp), color = Color(0xFF6B7280)) + } + HorizontalDivider(color = Color(0xFFE5E7EB)) + } + } + } + } +} + +// ── UI-Modell ───────────────────────────────────────────────────────────────── + +private data class NennungUiModel( + val startnr: Int, + val pferd: String, + val reiter: String, + val bewerb: String, + val status: String, +) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierNeuScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierNeuScreen.kt new file mode 100644 index 00000000..55b25a89 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierNeuScreen.kt @@ -0,0 +1,176 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import at.mocode.frontend.core.designsystem.models.PlaceholderContent + +private val PrimaryBlue = Color(0xFF1E3A8A) + +/** + * Formular zum Anlegen eines neuen Turniers (Vision_03: /veranstaltung/{id}/turnier/neu). + * Tabs: Übersicht | Stammdaten (A-Satz) | Organisation | Bewerbe ⭐ | Preisliste + */ +@Composable +fun TurnierNeuScreen( + veranstaltungId: Long, + onBack: () -> Unit, + onSave: () -> Unit, +) { + var selectedTab by remember { mutableIntStateOf(3) } // Bewerbe ist Standard-Tab (⭐) + val tabs = listOf("Stammdaten", "Organisation", "Bewerbe ⭐", "Preisliste") + + Column(modifier = Modifier.fillMaxSize().background(Color.White)) { + // Header + Surface(shadowElevation = 4.dp, color = Color.White) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + Spacer(Modifier.width(8.dp)) + Text( + text = "Neues Turnier (Veranstaltung #$veranstaltungId)", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } + Button( + onClick = onSave, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) + ) { + Text("Turnier speichern") + } + } + + Spacer(Modifier.height(16.dp)) + + // Tab Navigation as Stepper-like UI + PrimaryTabRow( + selectedTabIndex = selectedTab, + containerColor = Color.White, + contentColor = PrimaryBlue, + divider = {} + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + StepCircle( + number = (index + 1).toString(), + isActive = selectedTab == index, + isCompleted = index < selectedTab + ) + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal + ) + } + } + ) + } + } + } + } + + // Content Area + Box(modifier = Modifier.weight(1f).padding(32.dp)) { + when (selectedTab) { + 0 -> PlaceholderContent("Stammdaten", "OEPS-Turniernummer, Kategorie, Sparte …") + 1 -> PlaceholderContent("Organisation", "Richter, Parcourschef, Tierarzt …") + 2 -> { + // Hier binden wir später das reale Bewerbe-Tab ein + PlaceholderContent("Bewerbe", "Bewerbe anlegen und Abteilungen konfigurieren …") + } + 3 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …") + } + } + + // Footer Navigation + Surface(shadowElevation = 8.dp, color = Color.White) { + Row( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onBack) { + Text("Abbrechen") + } + + Row { + if (selectedTab > 0) { + OutlinedButton(onClick = { selectedTab-- }) { + Text("Zurück") + } + Spacer(Modifier.width(12.dp)) + } + if (selectedTab < tabs.size - 1) { + Button( + onClick = { selectedTab++ }, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) + ) { + Text("Weiter") + Spacer(Modifier.width(8.dp)) + Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(16.dp)) + } + } else { + Button( + onClick = onSave, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) + ) { + Text("Finalisieren & Speichern") + Spacer(Modifier.width(8.dp)) + Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp)) + } + } + } + } + } + } +} + +@Composable +private fun StepCircle(number: String, isActive: Boolean, isCompleted: Boolean) { + Box( + modifier = Modifier + .size(24.dp) + .background( + color = when { + isCompleted || isActive -> PrimaryBlue + else -> Color.LightGray + }, + shape = androidx.compose.foundation.shape.CircleShape + ), + contentAlignment = Alignment.Center + ) { + if (isCompleted) { + Icon(Icons.Default.Check, null, tint = Color.White, modifier = Modifier.size(14.dp)) + } else { + Text( + text = number, + color = Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierOnlineNennungenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierOnlineNennungenTab.kt new file mode 100644 index 00000000..be3ac2e7 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierOnlineNennungenTab.kt @@ -0,0 +1,108 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun OnlineNennungEingangTabContent(turnierNr: String, viewModel: TurnierNennungViewModel) { + val uiState by viewModel.uiState.collectAsState() + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text("Eingegangene Online-Nennungen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Turnier: $turnierNr", style = MaterialTheme.typography.bodyMedium, color = Color.Gray) + } + + Button( + onClick = { viewModel.loadOnlineNennungen() }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)), + enabled = !uiState.isOnlineLoading + ) { + if (uiState.isOnlineLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), color = Color.White, strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Refresh, contentDescription = null) + } + Spacer(Modifier.width(8.dp)) + Text("Aktualisieren") + } + } + + if (uiState.onlineNennungen.isEmpty() && !uiState.isOnlineLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Keine neuen Nennungen vorhanden.", color = Color.Gray) + } + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(uiState.onlineNennungen) { nennung -> + NennungEingangCard(nennung, onUebernehmen = { viewModel.uebernehmeOnlineNennung(nennung) }) + } + } + } + } +} + +@Composable +fun NennungEingangCard(nennung: OnlineNennung, onUebernehmen: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("${nennung.vorname} ${nennung.nachname}", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(Modifier.width(8.dp)) + Badge(containerColor = Color(0xFFE3F2FD)) { Text(nennung.lizenz, color = Color(0xFF1976D2)) } + } + Spacer(Modifier.height(4.dp)) + Text("Pferd: ${nennung.pferdName} (*${nennung.pferdAlter})", style = MaterialTheme.typography.bodyMedium) + Text("Bewerbe: ${nennung.bewerbe}", style = MaterialTheme.typography.bodySmall, color = Color(0xFF2E7D32), fontWeight = FontWeight.Bold) + } + + Column(horizontalAlignment = Alignment.End) { + Text(nennung.email, style = MaterialTheme.typography.labelSmall, color = Color.Gray) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { /* Details */ }, shape = RoundedCornerShape(8.dp)) { + Text("Details") + } + Button( + onClick = onUebernehmen, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32)) + ) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Übernehmen") + } + } + } + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierOrganisationTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierOrganisationTab.kt new file mode 100644 index 00000000..b495cee5 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierOrganisationTab.kt @@ -0,0 +1,523 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val PrimaryBlue = Color(0xFF1E3A8A) +private val AccentBlue = Color(0xFF3B82F6) +private val DeleteRed = Color(0xFFDC2626) + +/** + * ORGANISATION-Tab im TurnierDetailScreen. + * Gemäß Figma Vision_03 (figma-entwurf_13 / figma-entwurf_14): + * - Funktionäre & Offizielle (C-Satz): Turnierleiter, Turnierbeauftragter, Technischer Delegierter, Parcourschef + * - Support-Team: Tierarzt, Schmied, Steward + * - Richterkollegium: dynamische Liste (Name, Qualifikation, Funktion, Löschen) + * - Austragungsplätze: dynamische Liste (Sparte, Größe, Bezeichnung, Löschen) + */ +@Composable +fun OrganisationTabContent(viewModel: TurnierNennungViewModel) { + val state by viewModel.state.collectAsState() + + var turnierleiter by remember { mutableStateOf("") } + var turnierbeauftragter by remember { mutableStateOf("") } + var technischerDelegierter by remember { mutableStateOf("") } + var parcourschef by remember { mutableStateOf("") } + var tierarzt by remember { mutableStateOf("") } + var schmied by remember { mutableStateOf("") } + var steward by remember { mutableStateOf("") } + + // --- Dropdown-States für die Suche --- + var showTLDropdown by remember { mutableStateOf(false) } + var showTBDropdown by remember { mutableStateOf(false) } + var showTDDropdown by remember { mutableStateOf(false) } + var showPCDropdown by remember { mutableStateOf(false) } + var showTADropdown by remember { mutableStateOf(false) } + var showSMDropdown by remember { mutableStateOf(false) } + var showSTDropdown by remember { mutableStateOf(false) } + + var richter by remember { + mutableStateOf( + listOf( + RichterUiModel("Alexandra Schuster", "D-GP", "Hauptrichter"), + RichterUiModel("Ulrike Knasmüller-Prinz", "D-M", "Beisitzer"), + ), + ) + } + + var plaetze by remember { + mutableStateOf( + listOf( + AustragungsplatzUiModel("Dressur", "20 x 60 m", "Hauptplatz"), + AustragungsplatzUiModel("Dressur", "20 x 40 m", "Abreiteplatz 1"), + ), + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // ── Funktionäre & Offizielle ───────────────────────────────────────── + OrgSectionCard(title = "Funktionäre & Offizielle (C-Satz)") { + OrgSubSection("Turnier-Organisation") { + Box { + OrgSearchField("Turnierleiter:", turnierleiter) { + turnierleiter = it + viewModel.searchFunktionaere(it) + showTLDropdown = it.length >= 2 + } + DropdownMenu( + expanded = showTLDropdown && state.searchResultsFunktionaere.isNotEmpty(), + onDismissRequest = { showTLDropdown = false }, + properties = androidx.compose.ui.window.PopupProperties(focusable = false) + ) { + state.searchResultsFunktionaere.forEach { f -> + DropdownMenuItem( + text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, + onClick = { + turnierleiter = f.name + showTLDropdown = false + } + ) + } + } + } + Box { + OrgSearchField("Turnierbeauftragter:", turnierbeauftragter) { + turnierbeauftragter = it + viewModel.searchFunktionaere(it) + showTBDropdown = it.length >= 2 + } + DropdownMenu( + expanded = showTBDropdown && state.searchResultsFunktionaere.isNotEmpty(), + onDismissRequest = { showTBDropdown = false }, + properties = androidx.compose.ui.window.PopupProperties(focusable = false) + ) { + state.searchResultsFunktionaere.forEach { f -> + DropdownMenuItem( + text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, + onClick = { + turnierbeauftragter = f.name + showTBDropdown = false + } + ) + } + } + } + Box { + OrgSearchField("Technischer Delegierter:", technischerDelegierter) { + technischerDelegierter = it + viewModel.searchFunktionaere(it) + showTDDropdown = it.length >= 2 + } + DropdownMenu( + expanded = showTDDropdown && state.searchResultsFunktionaere.isNotEmpty(), + onDismissRequest = { showTDDropdown = false }, + properties = androidx.compose.ui.window.PopupProperties(focusable = false) + ) { + state.searchResultsFunktionaere.forEach { f -> + DropdownMenuItem( + text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, + onClick = { + technischerDelegierter = f.name + showTDDropdown = false + } + ) + } + } + } + Box { + OrgSearchField("Parcourschef:", parcourschef) { + parcourschef = it + viewModel.searchFunktionaere(it) + showPCDropdown = it.length >= 2 + } + DropdownMenu( + expanded = showPCDropdown && state.searchResultsFunktionaere.isNotEmpty(), + onDismissRequest = { showPCDropdown = false }, + properties = androidx.compose.ui.window.PopupProperties(focusable = false) + ) { + state.searchResultsFunktionaere.forEach { f -> + DropdownMenuItem( + text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, + onClick = { + parcourschef = f.name + showPCDropdown = false + } + ) + } + } + } + } + OrgSubSection("Support-Team") { + Box { + OrgSearchField("Tierarzt:", tierarzt) { + tierarzt = it + viewModel.searchFunktionaere(it) + showTADropdown = it.length >= 2 + } + DropdownMenu( + expanded = showTADropdown && state.searchResultsFunktionaere.isNotEmpty(), + onDismissRequest = { showTADropdown = false }, + properties = androidx.compose.ui.window.PopupProperties(focusable = false) + ) { + state.searchResultsFunktionaere.forEach { f -> + DropdownMenuItem( + text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, + onClick = { + tierarzt = f.name + showTADropdown = false + } + ) + } + } + } + Box { + OrgSearchField("Schmied:", schmied) { + schmied = it + viewModel.searchFunktionaere(it) + showSMDropdown = it.length >= 2 + } + DropdownMenu( + expanded = showSMDropdown && state.searchResultsFunktionaere.isNotEmpty(), + onDismissRequest = { showSMDropdown = false }, + properties = androidx.compose.ui.window.PopupProperties(focusable = false) + ) { + state.searchResultsFunktionaere.forEach { f -> + DropdownMenuItem( + text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, + onClick = { + schmied = f.name + showSMDropdown = false + } + ) + } + } + } + Box { + OrgSearchField("Steward:", steward) { + steward = it + viewModel.searchFunktionaere(it) + showSTDropdown = it.length >= 2 + } + DropdownMenu( + expanded = showSTDropdown && state.searchResultsFunktionaere.isNotEmpty(), + onDismissRequest = { showSTDropdown = false }, + properties = androidx.compose.ui.window.PopupProperties(focusable = false) + ) { + state.searchResultsFunktionaere.forEach { f -> + DropdownMenuItem( + text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, + onClick = { + steward = f.name + showSTDropdown = false + } + ) + } + } + } + } + } + + // ── Richterkollegium ───────────────────────────────────────────────── + OrgSectionCard( + title = "Richterkollegium", + action = { + TextButton(onClick = { + richter = richter + RichterUiModel("", "", "") + }) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue) + Spacer(Modifier.width(4.dp)) + Text("Richter hinzufügen", color = AccentBlue, fontSize = 13.sp) + } + }, + ) { + // Tabellen-Header + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) { + Text("Name", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f)) + Text("Qualifikation", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f)) + Text("Funktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f)) + Text("Aktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(48.dp)) + } + HorizontalDivider() + richter.forEachIndexed { idx, r -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Name-Suche mit Dropdown + var showRichterDropdown by remember { mutableStateOf(false) } + Box(modifier = Modifier.weight(3f).padding(end = 8.dp)) { + OutlinedTextField( + value = r.name, + onValueChange = { v -> + richter = richter.toMutableList().also { it[idx] = r.copy(name = v) } + viewModel.searchFunktionaere(v) + showRichterDropdown = v.length >= 2 + }, + modifier = Modifier.fillMaxWidth().height(44.dp), + singleLine = true, + placeholder = { Text("Name suchen...", fontSize = 12.sp) } + ) + DropdownMenu( + expanded = showRichterDropdown && state.searchResultsFunktionaere.isNotEmpty(), + onDismissRequest = { showRichterDropdown = false }, + properties = androidx.compose.ui.window.PopupProperties(focusable = false) + ) { + state.searchResultsFunktionaere.forEach { f -> + DropdownMenuItem( + text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, + onClick = { + richter = richter.toMutableList().also { it[idx] = r.copy(name = f.name) } + showRichterDropdown = false + } + ) + } + } + } + // Qualifikation-Dropdown + var qualExpanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) { + OutlinedTextField( + value = r.qualifikation, + onValueChange = {}, + readOnly = true, + trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }, + modifier = Modifier.fillMaxWidth().height(44.dp), + singleLine = true, + ) + DropdownMenu(expanded = qualExpanded, onDismissRequest = { qualExpanded = false }) { + listOf("D-GP", "D-M", "D-L", "S-GP", "S-M").forEach { q -> + DropdownMenuItem(text = { Text(q) }, onClick = { + richter = richter.toMutableList().also { it[idx] = r.copy(qualifikation = q) } + qualExpanded = false + }) + } + } + } + // Funktion-Dropdown + var funExpanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) { + OutlinedTextField( + value = r.funktion, + onValueChange = {}, + readOnly = true, + trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }, + modifier = Modifier.fillMaxWidth().height(44.dp), + singleLine = true, + ) + DropdownMenu(expanded = funExpanded, onDismissRequest = { funExpanded = false }) { + listOf("Hauptrichter", "Beisitzer", "Schreiber").forEach { f -> + DropdownMenuItem(text = { Text(f) }, onClick = { + richter = richter.toMutableList().also { it[idx] = r.copy(funktion = f) } + funExpanded = false + }) + } + } + } + IconButton( + onClick = { richter = richter.toMutableList().also { it.removeAt(idx) } }, + modifier = Modifier.size(44.dp), + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Löschen", + tint = DeleteRed, + modifier = Modifier.size(18.dp) + ) + } + } + } + } + + // ── Austragungsplätze ──────────────────────────────────────────────── + OrgSectionCard( + title = "Austragungsplätze", + ) { + OrgSubSection( + title = "Plätze & Anlagen", + action = { + TextButton(onClick = { + plaetze = plaetze + AustragungsplatzUiModel("", "", "") + }) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue) + Spacer(Modifier.width(4.dp)) + Text("Platz hinzufügen", color = AccentBlue, fontSize = 13.sp) + } + }, + ) { + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) { + Text("Sparte", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f)) + Text("Größe", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f)) + Text("Bezeichnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f)) + Text("Aktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(48.dp)) + } + HorizontalDivider() + plaetze.forEachIndexed { idx, p -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var sparteExpanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) { + OutlinedTextField( + value = p.sparte, + onValueChange = {}, + readOnly = true, + trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }, + modifier = Modifier.fillMaxWidth().height(44.dp), + singleLine = true, + ) + DropdownMenu(expanded = sparteExpanded, onDismissRequest = { sparteExpanded = false }) { + listOf("Dressur", "Springen", "Vielseitigkeit").forEach { s -> + DropdownMenuItem(text = { Text(s) }, onClick = { + plaetze = plaetze.toMutableList().also { it[idx] = p.copy(sparte = s) } + sparteExpanded = false + }) + } + } + } + var groesseExpanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) { + OutlinedTextField( + value = p.groesse, + onValueChange = {}, + readOnly = true, + trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }, + modifier = Modifier.fillMaxWidth().height(44.dp), + singleLine = true, + ) + DropdownMenu(expanded = groesseExpanded, onDismissRequest = { groesseExpanded = false }) { + listOf("20 x 60 m", "20 x 40 m", "60 x 80 m").forEach { g -> + DropdownMenuItem(text = { Text(g) }, onClick = { + plaetze = plaetze.toMutableList().also { it[idx] = p.copy(groesse = g) } + groesseExpanded = false + }) + } + } + } + OutlinedTextField( + value = p.bezeichnung, + onValueChange = { v -> plaetze = plaetze.toMutableList().also { it[idx] = p.copy(bezeichnung = v) } }, + modifier = Modifier.weight(3f).height(44.dp).padding(end = 8.dp), + singleLine = true, + ) + IconButton( + onClick = { plaetze = plaetze.toMutableList().also { it.removeAt(idx) } }, + modifier = Modifier.size(44.dp), + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Löschen", + tint = DeleteRed, + modifier = Modifier.size(18.dp) + ) + } + } + } + } + } + + // ── Speichern ──────────────────────────────────────────────────────── + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button( + onClick = {}, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + ) { Text("Speichern") } + } + } +} + +@Composable +private fun OrgSectionCard( + title: String, + action: @Composable (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue) + action?.invoke() + } + content() + } + } +} + +@Composable +private fun OrgSubSection( + title: String, + action: @Composable (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF9FAFB)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + action?.invoke() + } + content() + } + } +} + +@Composable +private fun OrgSearchField(label: String, value: String, onValueChange: (String) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + fontSize = 13.sp, + modifier = Modifier.weight(1.5f), // Flexibles Gewicht statt fixen 200dp + color = Color(0xFF374151) + ) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text("Name suchen...", fontSize = 12.sp) }, + modifier = Modifier.weight(3f), // Flexibles Gewicht und keine fixe Höhe + singleLine = true, + ) + } +} + +// --- UI-Modelle --- + +data class RichterUiModel(val name: String, val qualifikation: String, val funktion: String) +data class AustragungsplatzUiModel(val sparte: String, val groesse: String, val bezeichnung: String) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierStammdatenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierStammdatenTab.kt new file mode 100644 index 00000000..a27ed139 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierStammdatenTab.kt @@ -0,0 +1,630 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.time.LocalDate + +private val PrimaryBlue = Color(0xFF1E3A8A) +private val AccentBlue = Color(0xFF3B82F6) + +/** + * STAMMDATEN-Tab im TurnierDetailScreen. + * Gemäß Figma Vision_03 (figma-entwurf_16 / figma-entwurf_15): + * - Turnier-Konfiguration: Nr., Typ (OTO/FEI), ZNS-Import, Sprache + * - Sparten-Checkboxen, Klassen, Kategorien, Datum + * - Turnier-Beschreibung: Titel, Sub-Titel + * - Sponsoren + */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StammdatenTabContent( + turnierId: Long, + eventVon: String? = null, + eventBis: String? = null, + eventOrt: String? = null, + veranstalterName: String? = null, + veranstalterOrt: String? = null, + veranstalterBundesland: String? = null, + veranstalterLogoUrl: String? = null, +) { + // In einer echten App würden wir diese Daten aus einem ViewModel laden. + // Hier simulieren wir den State basierend auf den Anforderungen. + + var turnierNr by remember { mutableStateOf("") } + var nrConfirmed by remember { mutableStateOf(false) } + var showNrConfirm by remember { mutableStateOf(false) } + var znsDataLoaded by remember { mutableStateOf(false) } + var znsPayloadVersion by remember { mutableStateOf(null) } + var znsImportedAt by remember { mutableStateOf(null) } + val znsImportHistory = + remember { mutableStateListOf>() } // (source, payloadVersion, ok) + var typ by remember { mutableStateOf("ÖTO (National)") } + + val sparten = remember { mutableStateListOf() } + val klassen = remember { mutableStateListOf() } + val kat = remember { mutableStateListOf() } + + var von by remember { mutableStateOf(eventVon ?: "") } + var bis by remember { mutableStateOf(eventBis ?: "") } + var ort by remember { mutableStateOf(eventOrt ?: "") } + + var titel by remember { mutableStateOf("") } + var subTitel by remember { mutableStateOf("") } + + // Initialisierung aus Repository + LaunchedEffect(turnierId) { + // In einer echten Architektur kommt dies über das Repository. + // Aber für die Demo/Fakten-Präsentation im Desktop-Shell-Kontext: + try { + val clazz = Class.forName("at.mocode.frontend.shell.desktop.data.TurnierStore") + val method = clazz.getMethod("allTurniere") + val all = method.invoke(null) as? List<*> + val turnier = all?.find { t -> + val idField = t!!::class.java.getDeclaredField("turnierNr") + idField.isAccessible = true + idField.get(t).toString() == turnierId.toString() || + t.hashCode().toLong() == turnierId // Fallback, falls die ID anders gemappt ist + } + + when { + turnier != null -> { + val tClass = turnier::class.java + + val nrField = tClass.getDeclaredField("turnierNr") + nrField.isAccessible = true + turnierNr = nrField.get(turnier).toString() + nrConfirmed = true + + val titelField = tClass.getDeclaredField("titel") + titelField.isAccessible = true + titel = titelField.get(turnier) as String + + val subField = tClass.getDeclaredField("subTitel") + subField.isAccessible = true + subTitel = subField.get(turnier) as String + + val katField = tClass.getDeclaredField("kategorie") + katField.isAccessible = true + val kats = katField.get(turnier) as? List + kats?.let { + kat.clear() + kat.addAll(it) + } + + val typField = tClass.getDeclaredField("typ") + typField.isAccessible = true + typ = typField.get(turnier) as String + + val znsField = tClass.getDeclaredField("znsDataLoaded") + znsField.isAccessible = true + znsDataLoaded = znsField.get(turnier) as Boolean + } + } + } catch (_: Exception) { + // Reflection fehlgeschlagen oder Store nicht erreichbar -> Fallback auf leere Felder + } + } + var turnierLogoUrl by remember { mutableStateOf("") } + val sponsoren = remember { mutableStateListOf() } + + var showZnsDialog by remember { mutableStateOf(false) } + var showZnsLog by remember { mutableStateOf(false) } + + // Hilf's-States für DatePicker + var showDatePickerVon by remember { mutableStateOf(false) } + var showDatePickerBis by remember { mutableStateOf(false) } + + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // ── Turnier-Konfiguration (Schritt 1 Logik) ─────────────────────────── + SectionCard(title = "Turnier-Konfiguration & ZNS") { + FormRow("Turnier-Nr.:") { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = turnierNr, + onValueChange = { if (it.length <= 5 && it.all { c -> c.isDigit() }) turnierNr = it }, + placeholder = { Text("5-stellig", fontSize = 13.sp) }, + modifier = Modifier.width(120.dp), + singleLine = true, + enabled = !nrConfirmed + ) + when { + !nrConfirmed -> { + Button( + onClick = { showNrConfirm = true }, + enabled = turnierNr.length == 5, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) + ) { + Text("Bestätigen") + } + } + + else -> { + InputChip( + selected = true, + onClick = { }, + label = { Text("Bestätigt") }, + trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } + ) + } + } + } + when (turnierNr.length) { + 5 if !nrConfirmed -> { + Text( + "Bitte Turnier-Nummer bestätigen um fortzufahren.", + color = MaterialTheme.colorScheme.error, + fontSize = 11.sp + ) + } + } + } + + FormRow("Typ:") { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { + FilterChip( + selected = typ == "ÖTO (National)", + onClick = { typ = "ÖTO (National)" }, + enabled = nrConfirmed, + label = { Text("ÖTO (National)") } + ) + FilterChip( + selected = typ == "FEI (International)", + onClick = { typ = "FEI (International)" }, + enabled = nrConfirmed, + label = { Text("FEI (International)") } + ) + } + } + + FormRow("ZNS-Stammdaten:") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = { showZnsDialog = true }, + colors = ButtonDefaults.buttonColors(containerColor = AccentBlue), enabled = nrConfirmed + ) { + Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Import via Internet") + } + OutlinedButton(onClick = { showZnsDialog = true }, enabled = nrConfirmed) { + Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Import via USB") + } + TextButton(onClick = { showZnsLog = true }, enabled = nrConfirmed) { Text("Import-Log anzeigen…") } + } + + val znsStatusColor = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = znsStatusColor, + modifier = Modifier.size(16.dp) + ) + Text( + if (znsDataLoaded) "ZNS-Daten geladen" else "Keine ZNS-Daten geladen", + color = znsStatusColor, + fontWeight = FontWeight.Bold, + fontSize = 13.sp + ) + if (znsDataLoaded) { + Spacer(Modifier.width(8.dp)) + Text( + listOfNotNull( + znsPayloadVersion?.let { "Version: $it" }, + znsImportedAt?.let { "Zeit: $it" }, + ).joinToString(" • "), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp + ) + } + } + } + } + + // ── Sparten & Kategorien (Schritt 2 Logik) ─────────────────────────── + SectionCard(title = "Reglement & Sparten") { + FormRow("Sparte:") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = sparten.contains("Dressur"), + onClick = { if (sparten.contains("Dressur")) sparten.remove("Dressur") else sparten.add("Dressur") }, + enabled = nrConfirmed, + label = { Text("Dressur") } + ) + FilterChip( + selected = sparten.contains("Springen"), + onClick = { if (sparten.contains("Springen")) sparten.remove("Springen") else sparten.add("Springen") }, + enabled = nrConfirmed, + label = { Text("Springen") } + ) + } + } + + FormRow("Klasse:") { + val klassenListe = listOf("C-NEU", "C", "B", "A") + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + klassenListe.forEach { k -> + FilterChip( + selected = klassen.contains(k), + onClick = { if (klassen.contains(k)) klassen.remove(k) else klassen.add(k) }, + enabled = nrConfirmed, + label = { Text(k) } + ) + } + } + } + + FormRow("Kategorien:") { + // Logik zur Generierung der Kategorien + val suggested = mutableListOf() + sparten.forEach { s -> + val prefix = if (s == "Dressur") "CDN" else "CSN" + klassen.forEach { k -> + suggested.add("$prefix-$k") + suggested.add("${prefix}P-$k") // Pony Variante + } + } + + when { + suggested.isEmpty() -> { + Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp) + } + + else -> { + // Gruppiere nach Sparte (CDN/CSN) + val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" } + grouped.forEach { (gruppe, eintraege) -> + Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue) + Spacer(Modifier.height(4.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + eintraege.sorted().forEach { c -> + InputChip( + selected = kat.contains(c), + onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) }, + enabled = nrConfirmed, + label = { Text(c) } + ) + } + } + Spacer(Modifier.height(8.dp)) + } + } + } + } + + FormRow("Zeitraum:") { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + val vonMod = + if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp) + OutlinedTextField( + value = von, + onValueChange = {}, + label = { Text("Von") }, + modifier = vonMod, + readOnly = true, + enabled = nrConfirmed, + trailingIcon = { Icon(Icons.Default.DateRange, null) } + ) + Text("bis") + val bisMod = + if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp) + OutlinedTextField( + value = bis, + onValueChange = {}, + label = { Text("Bis") }, + modifier = bisMod, + readOnly = true, + enabled = nrConfirmed, + trailingIcon = { Icon(Icons.Default.DateRange, null) } + ) + } + val rangeText = + if (eventVon != null && eventBis != null) "Muss zwischen $eventVon – $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen." + Text(rangeText, fontSize = 11.sp, color = Color.Gray) + } + } + + // ── Branding (Schritt 3 Logik) ─────────────────────────────────────── + SectionCard(title = "Turnier-Branding") { + // Default-Titel-Vorschlag: [Kategorien] [Verein-Ort] [Bundesland] + val defaultTitle = remember(kat.size, veranstalterOrt, veranstalterBundesland) { + val cats = if (kat.isEmpty()) "" else kat.sorted().joinToString(" ") + listOfNotNull( + cats.ifBlank { null }, + listOfNotNull(veranstalterOrt, veranstalterBundesland).filter { it.isNotBlank() }.joinToString(" ") + .takeIf { it.isNotBlank() } + ).joinToString(" ") + } + OutlinedTextField( + value = titel, + onValueChange = { titel = it }, + label = { Text("Titel") }, + placeholder = { if (defaultTitle.isNotBlank()) Text(defaultTitle) }, + enabled = nrConfirmed, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = subTitel, + onValueChange = { subTitel = it }, + label = { Text("Sub-Titel") }, + enabled = nrConfirmed, + modifier = Modifier.fillMaxWidth() + ) + + // Ort im Branding-Bereich platzieren (mit Soft-Warnung bei Abweichung zum Veranstaltungsort) + FormRow("Ort:") { + OutlinedTextField( + value = ort, + onValueChange = { ort = it }, + label = { Text("Austragungsort") }, + enabled = nrConfirmed, + modifier = Modifier.fillMaxWidth(), + supportingText = { + if (eventOrt != null && ort.isNotBlank() && ort.trim() != eventOrt.trim()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = Color(0xFFF59E0B), + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Abweichung zum Veranstaltungsort ($eventOrt) – bitte prüfen.", color = Color(0xFFF59E0B)) + } + } else { + Text("Muss mit Veranstaltungsort übereinstimmen.") + } + } + ) + } + + // Turnier-Logo mit Fallback auf Veranstalterlogo + OutlinedTextField( + value = turnierLogoUrl, + onValueChange = { turnierLogoUrl = it }, + label = { Text("Turnier-Logo (URL/Pfad)") }, + enabled = nrConfirmed, + supportingText = { + Text("Wenn leer: verwende Veranstalter-Logo${if (!veranstalterLogoUrl.isNullOrBlank()) " ($veranstalterLogoUrl)" else ""}.") + }, + modifier = Modifier.fillMaxWidth() + ) + + FormRow("Sponsoren:") { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + sponsoren.forEach { s -> + InputChip( + selected = true, + onClick = { sponsoren.remove(s) }, + enabled = nrConfirmed, + label = { Text(s) }, + trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(14.dp)) } + ) + } + TextButton(onClick = { sponsoren.add("Neuer Sponsor") }, enabled = nrConfirmed) { + Text("+ Hinzufügen") + } + } + } + } + + // ── Footer ────────────────────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Save-Enable-Matrix (kleine Checkliste) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + AssistChip(onClick = {}, label = { Text("Nr bestätigt") }, leadingIcon = { + Icon( + if (nrConfirmed) Icons.Default.Check else Icons.Default.Close, + null, + tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + ) + }) + AssistChip(onClick = {}, label = { Text("ZNS geladen") }, leadingIcon = { + Icon( + if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close, + null, + tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + ) + }) + val dateOk = remember(von, bis, eventVon, eventBis) { + try { + if (eventVon == null || eventBis == null || von.isBlank()) true else { + val evV = LocalDate.parse(eventVon) + val evB = LocalDate.parse(eventBis) + val tV = LocalDate.parse(von) + val tB = if (bis.isBlank()) tV else LocalDate.parse(bis) + !tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV) + } + } catch (_: Exception) { + false + } + } + AssistChip(onClick = {}, label = { Text("Datum gültig") }, leadingIcon = { + Icon( + if (dateOk) Icons.Default.Check else Icons.Default.Close, + null, + tint = if (dateOk) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + ) + }) + } + + Button( + onClick = { /* Speichern */ }, + enabled = run { + val base = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank() + val dateValid = try { + if (eventVon == null || eventBis == null || von.isBlank()) true else { + val evV = LocalDate.parse(eventVon) + val evB = LocalDate.parse(eventBis) + val tV = LocalDate.parse(von) + val tB = if (bis.isBlank()) tV else LocalDate.parse(bis) + !tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV) + } + } catch (_: Exception) { + false + } + base && dateValid + }, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + modifier = Modifier.padding(bottom = 24.dp) + ) { + Icon(Icons.Default.Save, null) + Spacer(Modifier.width(8.dp)) + Text("Änderungen speichern") + } + } + } + + // Dialog-Simulationen + when { + showZnsDialog -> { + AlertDialog( + onDismissRequest = { showZnsDialog = false }, + title = { Text("ZNS Import") }, + text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") }, + confirmButton = { + TextButton(onClick = { + znsDataLoaded = true + znsPayloadVersion = "v2.4" + znsImportedAt = java.time.Instant.now().toString() + znsImportHistory.add(Triple("Internet/USB", znsPayloadVersion!!, true)) + showZnsDialog = false + }) { Text("Importieren") } + }, + dismissButton = { + TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") } + } + ) + } + } + + when { + showNrConfirm -> { + AlertDialog( + onDismissRequest = { showNrConfirm = false }, + title = { Text("Turnier-Nummer bestätigen?") }, + text = { Text("Die Turnier-Nr. ist nach der Bestätigung nicht mehr änderbar.") }, + confirmButton = { + TextButton(onClick = { nrConfirmed = true; showNrConfirm = false }) { Text("Ja, bestätigen") } + }, + dismissButton = { + TextButton(onClick = { showNrConfirm = false }) { Text("Abbrechen") } + } + ) + } + } + + when { + showZnsLog -> { + AlertDialog( + onDismissRequest = { showZnsLog = false }, + title = { Text("ZNS Import-Log (letzte 5)") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (znsImportHistory.isEmpty()) { + Text("Keine Einträge vorhanden.", color = Color.Gray) + } else { + znsImportHistory.takeLast(5).asReversed().forEach { (src, ver, ok) -> + val c = if (ok) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + Text("• $src – Version $ver – ${if (ok) "OK" else "Fehler"}", color = c, fontSize = 13.sp) + } + } + } + }, + confirmButton = { TextButton(onClick = { showZnsLog = false }) { Text("Schließen") } } + ) + } + } + + when { + showDatePickerVon -> { + val state = rememberDatePickerState() + DatePickerDialog( + onDismissRequest = { showDatePickerVon = false }, + confirmButton = { + TextButton(onClick = { + state.selectedDateMillis?.let { + von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString() + } + showDatePickerVon = false + }) { Text("OK") } + } + ) { DatePicker(state) } + } + + showDatePickerBis -> { + val state = rememberDatePickerState() + DatePickerDialog( + onDismissRequest = { showDatePickerBis = false }, + confirmButton = { + TextButton(onClick = { + state.selectedDateMillis?.let { + bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString() + } + showDatePickerBis = false + }) { Text("OK") } + } + ) { DatePicker(state) } + } + } +} + +@Composable +private fun SectionCard( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = PrimaryBlue, fontWeight = FontWeight.Bold) + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) + content() + } + } +} + +@Composable +private fun FormRow(label: String, content: @Composable ColumnScope.() -> Unit) { + Row(Modifier.fillMaxWidth()) { + Text( + label, + modifier = Modifier.width(140.dp).padding(top = 12.dp), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + content() + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierStartlistenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierStartlistenTab.kt new file mode 100644 index 00000000..39b591ed --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierStartlistenTab.kt @@ -0,0 +1,241 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.background +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile +import at.mocode.frontend.features.turnier.domain.Bewerb +import org.koin.compose.koinInject + +private val SlBlue = Color(0xFF1E3A8A) +private val SlHeaderBg = Color(0xFFF1F5F9) + +/** + * STARTLISTEN-Tab gemäß Vision_03. + * + * Layout: 2-spaltig + * - Links (flex): Bewerbs-Tabs + Starter-Tabelle (Startnr | Pferd | Reiter | Abteilung | Beginn) + * - Rechts (280dp): Sortierung & Zeit-Panel + */ +@Composable +fun StartlistenTabContent( + viewModel: BewerbViewModel = koinInject() +) { + val state by viewModel.state.collectAsState() + val selectedBewerb = state.list.find { it.id == state.selectedId } + + Row(modifier = Modifier.fillMaxSize()) { + // ── Linke Spalte: Bewerbs-Tabs + Tabelle ───────────────────────────── + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + StartlistenBewerbsTabs( + bewerbe = state.list, + selectedId = state.selectedId, + onSelect = { viewModel.send(BewerbIntent.Select(it)) }, + currentStartliste = state.currentStartliste, + onGenerate = { viewModel.generateStartliste() }, + onRowClick = { viewModel.send(BewerbIntent.OpenErgebnisEdit(it)) } + ) + } + + VerticalDivider() + + // ── 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 +private fun StartlistenBewerbsTabs( + bewerbe: List, + selectedId: Long?, + onSelect: (Long?) -> Unit, + currentStartliste: List, + onGenerate: () -> Unit, + onRowClick: (StartlistenZeile) -> Unit +) { + val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0) + + PrimaryScrollableTabRow( + selectedTabIndex = selectedIndex, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = SlBlue, + edgePadding = 0.dp, + ) { + bewerbe.forEachIndexed { index, bewerb -> + Tab( + selected = selectedId == bewerb.id, + onClick = { onSelect(bewerb.id) }, + text = { Text(bewerb.tag, fontSize = 12.sp) }, + ) + } + } + HorizontalDivider() + + val selectedBewerb = bewerbe.getOrNull(selectedIndex) + + // Toolbar + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = selectedBewerb?.let { "${it.tag} – ${it.name}" } ?: "Kein Bewerb ausgewählt", + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ) + Spacer(Modifier.weight(1f)) + OutlinedButton( + onClick = {}, + modifier = Modifier.height(32.dp), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Text("Drucken", fontSize = 12.sp) + } + OutlinedButton( + onClick = {}, + modifier = Modifier.height(32.dp), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Text("Exportieren", fontSize = 12.sp) + } + } + + // Tabellen-Header + Row( + modifier = Modifier.fillMaxWidth().background(SlHeaderBg).padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp)) + Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Abteilung", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp)) + Text("Beginn", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp)) + } + HorizontalDivider() + + if (currentStartliste.isEmpty()) { + // Leere Liste + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Keine Starter vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280)) + Spacer(Modifier.height(8.dp)) + Text("Startliste wird nach Nennungsschluss generiert.", fontSize = 12.sp, color = Color(0xFF9CA3AF)) + Spacer(Modifier.height(16.dp)) + Button( + onClick = onGenerate, + colors = ButtonDefaults.buttonColors(containerColor = SlBlue), + ) { + Text("Startliste generieren", fontSize = 13.sp) + } + } + } + } else { + // Liste anzeigen + androidx.compose.foundation.lazy.LazyColumn(modifier = Modifier.fillMaxSize()) { + items(currentStartliste.size) { index -> + val zeile = currentStartliste[index] + Row( + 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)) + Text(zeile.pferd, fontSize = 12.sp, modifier = Modifier.weight(1f)) + Text(zeile.reiter, fontSize = 12.sp, modifier = Modifier.weight(1f)) + Text("-", fontSize = 12.sp, modifier = Modifier.width(80.dp)) + Text(zeile.zeit, fontSize = 12.sp, modifier = Modifier.width(70.dp)) + } + HorizontalDivider(color = Color(0xFFE5E7EB)) + } + } + } +} + +@Composable +private fun StartlistenSortierPanel(modifier: Modifier = Modifier) { + Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Sortierung & Zeit", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + HorizontalDivider() + + // Sortierung + Text("Sortierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + SortierOption("Aufsteigend (Startnummer)") + SortierOption("Absteigend (Startnummer)") + SortierOption("Auslosung (zufällig)") + SortierOption("Alphabetisch (Pferd)") + SortierOption("Alphabetisch (Reiter)") + } + + HorizontalDivider() + + // Zeiten + Text("Zeiten", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium) + LabeledInput("Beginnzeit:", "08:00", "(hh:mm)") + LabeledInput("Reitdauer:", "02:00", "(mm:ss)") + LabeledInput("Umbau:", "10", "(mm)") + LabeledInput("Besichtigung:", "10", "(mm)") + + HorizontalDivider() + + Button( + onClick = {}, + colors = ButtonDefaults.buttonColors(containerColor = SlBlue), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Zeiten neu berechnen", fontSize = 12.sp) + } + } +} + +@Composable +private fun SortierOption(label: String) { + var selected by remember { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { + RadioButton(selected = selected, onClick = { selected = !selected }) + Text(label, fontSize = 12.sp) + } +} + +@Composable +private fun LabeledInput(label: String, value: String, unit: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, fontSize = 12.sp, modifier = Modifier.width(100.dp)) + OutlinedTextField( + value = value, + onValueChange = {}, + modifier = Modifier.width(70.dp), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 12.sp), + ) + Spacer(Modifier.width(6.dp)) + Text(unit, fontSize = 11.sp, color = Color(0xFF6B7280)) + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierViewModel.kt new file mode 100644 index 00000000..e152c605 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierViewModel.kt @@ -0,0 +1,106 @@ +package at.mocode.frontend.features.turnier.presentation + +import at.mocode.frontend.features.turnier.domain.Turnier +import at.mocode.frontend.features.turnier.domain.TurnierRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +// UI-optimiertes List Item für Turniere (unabhängig vom Domänenmodell) +data class TurnierListItem( + val id: Long, + val name: String, + val ort: String, + val startDatum: String, + val endDatum: String, + val status: String, +) + +data class TurnierState( + val isLoading: Boolean = false, + val searchQuery: String = "", + val list: List = emptyList(), + val filtered: List = emptyList(), + val selectedId: Long? = null, + val errorMessage: String? = null, +) + +sealed interface TurnierIntent { + data object Load : TurnierIntent + data object Refresh : TurnierIntent + data class SearchChanged(val query: String) : TurnierIntent + data class Select(val id: Long?) : TurnierIntent + data object ClearError : TurnierIntent +} + +class TurnierViewModel( + private val repo: TurnierRepository, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _state = MutableStateFlow(TurnierState(isLoading = true)) + val state: StateFlow = _state + + init { + send(TurnierIntent.Load) + } + + fun send(intent: TurnierIntent) { + when (intent) { + is TurnierIntent.Load -> load() + is TurnierIntent.Refresh -> load() + is TurnierIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() } + is TurnierIntent.Select -> reduce { it.copy(selectedId = intent.id) } + is TurnierIntent.ClearError -> reduce { it.copy(errorMessage = null) } + } + } + + private fun load() { + reduce { it.copy(isLoading = true, errorMessage = null) } + scope.launch { + repo.list() + .onSuccess { list -> + val items = list.map { it.toListItem() } + reduce { cur -> + val filtered = filterList(items, cur.searchQuery) + cur.copy(isLoading = false, list = items, filtered = filtered) + } + } + .onFailure { t -> + reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") } + } + } + } + + private fun Turnier.toListItem() = TurnierListItem( + id = id, + name = name, + ort = "Stadl-Paura", // Platzhalter bis API erweitert + startDatum = "2026-05-01", + endDatum = "2026-05-03", + status = "AKTIV" + ) + + private fun filter() { + val cur = _state.value + val filtered = filterList(cur.list, cur.searchQuery) + reduce { it.copy(filtered = filtered) } + } + + private fun filterList(list: List, query: String): List { + if (query.isBlank()) return list + val q = query.trim() + return list.filter { + it.name.contains(q, ignoreCase = true) || + it.ort.contains(q, ignoreCase = true) || + it.status.contains(q, ignoreCase = true) + } + } + + private inline fun reduce(block: (TurnierState) -> TurnierState) { + _state.value = block(_state.value) + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierZeitplanTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierZeitplanTab.kt new file mode 100644 index 00000000..018ef07a --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/TurnierZeitplanTab.kt @@ -0,0 +1,412 @@ +package at.mocode.frontend.features.turnier.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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, + viewModel: BewerbViewModel +) { + 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(), + conflictMessage = bewerb.warnungen.joinToString("\n") { it.nachricht } + ) + } + + val scrollState = rememberScrollState() + var showAuditLog by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) { + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.weight(1f)) { + // Header / Toolbar + ZeitplanToolbar(viewModel = viewModel, onShowHistory = { showAuditLog = !showAuditLog }) + + 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.forEach { item -> + DraggableBewerbBox( + item = item, + onPositionChange = { 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)) + }, + onClick = { + viewModel.send(BewerbIntent.Select(item.id)) + viewModel.send(BewerbIntent.LoadAuditLog(item.id)) + } + ) + } + } + } + } + + if (showAuditLog) { + VerticalDivider(color = SlotBorder) + AuditLogSektion( + state = state, + modifier = Modifier.width(300.dp).fillMaxHeight() + ) + } + } + + if (state.showExportDialog && state.exportContent != null) { + AlertDialog( + onDismissRequest = { viewModel.send(BewerbIntent.CloseExportDialog) }, + title = { Text("ZNS B-Satz Export") }, + text = { + Column { + Text("Der Export für den ZNS B-Satz wurde generiert. Kopiere den Inhalt in deine n2-Datei.") + Spacer(Modifier.height(8.dp)) + val content = state.exportContent ?: "" + OutlinedTextField( + value = content, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth().height(200.dp), + textStyle = MaterialTheme.typography.bodySmall + ) + } + }, + confirmButton = { + Button(onClick = { viewModel.send(BewerbIntent.CloseExportDialog) }) { + Text("Schließen") + } + } + ) + } + } +} + +@Composable +private fun ZeitplanToolbar( + viewModel: BewerbViewModel, + onShowHistory: () -> Unit = {} +) { + 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)) + + TextButton(onClick = onShowHistory) { + Text("Historie anzeigen", color = ZeitplanBlue, fontSize = 13.sp) + } + + // 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 = { viewModel.send(BewerbIntent.ExportZnsBSatz) }, + colors = ButtonDefaults.buttonColors(containerColor = ZeitplanBlue) + ) { + Text("B-Satz Export (ZNS)", fontSize = 13.sp) + } + } +} + +@Composable +private fun AuditLogSektion( + state: BewerbState, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.background(Color.White).padding(16.dp)) { + Text( + text = "ÄNDERUNGS-HISTORIE", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = HourLabelColor + ) + Spacer(Modifier.height(12.dp)) + + if (state.selectedId == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Wähle einen Bewerb aus,\num die Historie zu sehen.", color = HourLabelColor, fontSize = 12.sp) + } + } else if (state.isAuditLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = ZeitplanBlue) + } + } else if (state.auditLog.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Keine Änderungen erfasst.", color = HourLabelColor, fontSize = 12.sp) + } + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(state.auditLog) { entry -> + AuditLogItem(entry) + } + } + } + } +} + +@Composable +private fun AuditLogItem(entry: at.mocode.frontend.features.turnier.domain.AuditLogEntry) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(ZeitplanBg, RoundedCornerShape(4.dp)) + .padding(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(6.dp).background(ZeitplanBlue, RoundedCornerShape(3.dp))) + Spacer(Modifier.width(8.dp)) + Text( + text = entry.action, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + Spacer(Modifier.weight(1f)) + Text( + text = entry.timestamp.split("T").lastOrNull()?.take(5) ?: "", + fontSize = 10.sp, + color = HourLabelColor + ) + } + if (entry.changesJson != null) { + Spacer(Modifier.height(4.dp)) + Text( + text = entry.changesJson, + fontSize = 10.sp, + color = Color.DarkGray, + lineHeight = 14.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, + onClick: () -> 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)) + .clickable(onClick = onClick) + .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( + text = item.conflictMessage.ifEmpty { "Konflikt" }, + fontSize = 10.sp, + color = Color.Red, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + } + } + } + } +} + +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 conflictMessage: String = "" +) { + 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/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterAuswahlScreen.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterAuswahlScreen.kt index 0d0e41fd..3f9e28b2 100644 --- a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterAuswahlScreen.kt +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterAuswahlScreen.kt @@ -1,15 +1,14 @@ package at.mocode.frontend.features.veranstalter.presentation import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -27,15 +26,7 @@ private val AccentBlue = Color(0xFF3B82F6) /** * Screen: "Admin - Verwaltung / Veranstalter auswählen" - * - * Gemäß Figma Vision_03 (figma-entwurf_20 / figma-entwurf_22): - * - Titel + Untertitel - * - Suchfeld + "+ Neuer Veranstalter"-Button - * - Tabelle: Vereinsname, OEPS-Nummer, Ort, Ansprechpartner, E-Mail, Login-Status - * - Hinweis-Box - * - Abbrechen / "Weiter zum Veranstalter"-Buttons (unten) - * - * TODO: Echte Daten aus customer-context laden (Phase 4/5). + * Optimiert für Vision_03 mit dichter Tabellenansicht für Profis. */ @Composable fun VeranstalterAuswahlScreen( @@ -43,115 +34,130 @@ fun VeranstalterAuswahlScreen( onWeiter: (Long) -> Unit, onNeuerVeranstalter: () -> Unit = {}, ) { - // MVVM + UDF: ViewModel hält gesamten Zustand, Composable rendert nur State und sendet Intents val repo: at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository = org.koin.compose.koinInject() val viewModel = remember { VeranstalterViewModel(repo) } val state by viewModel.state.collectAsState() - Column(modifier = Modifier.fillMaxSize()) { - + Column(modifier = Modifier.fillMaxSize().background(Color.White)) { // ── Titel ──────────────────────────────────────────────────────────── - Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { - Text( - text = "Veranstalter für neue Veranstaltung auswählen", - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - ) - Text( - text = "Wählen Sie einen bestehenden Veranstalter aus oder legen Sie einen neuen Veranstalter an.", - fontSize = 13.sp, - color = Color(0xFF6B7280), - ) + Surface(shadowElevation = 4.dp, color = Color.White) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "Veranstalter auswählen", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "Wählen Sie den Verein für die neue Veranstaltung aus.", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + } + IconButton(onClick = onZurueck) { + Icon(Icons.Default.Close, contentDescription = "Schließen") + } + } } // ── Suchfeld + Neuer Veranstalter ──────────────────────────────────── Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp), + .padding(24.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { OutlinedTextField( value = state.searchQuery, onValueChange = { viewModel.send(VeranstalterIntent.SearchChanged(it)) }, - placeholder = { Text("Veranstalter suchen (Name, OEPS-Nummer, Ort)...", fontSize = 13.sp) }, + placeholder = { Text("Suche nach Name, OEPS-Nummer oder Ort...", fontSize = 14.sp) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - modifier = Modifier.weight(1f).height(48.dp), + modifier = Modifier.weight(1f), singleLine = true, ) Button( onClick = onNeuerVeranstalter, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + shape = MaterialTheme.shapes.medium ) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) Text("Neuer Veranstalter") } } // ── Tabellen-Header ────────────────────────────────────────────────── - Row( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFFF3F4F6)) - .padding(horizontal = 24.dp, vertical = 8.dp), + Surface( + color = Color(0xFFF9FAFB), + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp) ) { - Spacer(Modifier.width(28.dp)) // Checkmark-Spalte - Text("Vereinsname", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(2.5f)) - Text("OEPS-Nummer", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f)) - Text("Ort", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f)) - Text("Ansprechpartner", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f)) - Text("E-Mail", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(2f)) - Text("Login", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1f)) + Row( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(Modifier.width(32.dp)) // Platz für Checkmark/Radio + Text("Vereinsname", fontWeight = FontWeight.Bold, fontSize = 12.sp, modifier = Modifier.weight(2.5f)) + Text("OEPS-Nummer", fontWeight = FontWeight.Bold, fontSize = 12.sp, modifier = Modifier.weight(1.5f)) + Text("Ort", fontWeight = FontWeight.Bold, fontSize = 12.sp, modifier = Modifier.weight(1.5f)) + Text("Ansprechpartner", fontWeight = FontWeight.Bold, fontSize = 12.sp, modifier = Modifier.weight(1.5f)) + Text("E-Mail", fontWeight = FontWeight.Bold, fontSize = 12.sp, modifier = Modifier.weight(2f)) + Text("Status", fontWeight = FontWeight.Bold, fontSize = 12.sp, modifier = Modifier.weight(1f)) + } } - HorizontalDivider() // ── Tabellen-Inhalt ────────────────────────────────────────────────── - LazyColumn(modifier = Modifier.weight(1f)) { + LazyColumn( + modifier = Modifier.weight(1f).padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { items(state.filtered) { v -> val isSelected = v.id == state.selectedId - Row( + Card( modifier = Modifier .fillMaxWidth() - .background( - if (isSelected) AccentBlue.copy(alpha = 0.08f) - else Color.Transparent, - ) .clickable { viewModel.send(VeranstalterIntent.Select(v.id)) } - .padding(horizontal = 24.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Auswahl-Checkmark - Box(modifier = Modifier.width(28.dp)) { - if (isSelected) { - Icon( - Icons.Default.Check, - contentDescription = null, - tint = AccentBlue, - modifier = Modifier.size(18.dp), - ) - } - } - Text( - text = v.name, - fontSize = 13.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - color = AccentBlue, - modifier = Modifier.weight(2.5f), + .border( + width = 1.dp, + color = if (isSelected) PrimaryBlue else Color(0xFFE5E7EB), + shape = MaterialTheme.shapes.small + ), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) Color(0xFFEFF6FF) else Color.White ) - Text(v.oepsNummer, fontSize = 13.sp, modifier = Modifier.weight(1.5f)) - Text(v.ort, fontSize = 13.sp, modifier = Modifier.weight(1.5f)) - // Placeholder für Ansprechpartner/E-Mail vorerst leer im ListItem-Model - Text("-", fontSize = 13.sp, modifier = Modifier.weight(1.5f)) - Text("-", fontSize = 13.sp, modifier = Modifier.weight(2f)) - // Login-Status-Badge - Box(modifier = Modifier.weight(1f)) { - // Für die Referenz reicht String-Label - Text(v.loginStatus, fontSize = 12.sp, color = Color(0xFF111827)) + ) { + Row( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = { viewModel.send(VeranstalterIntent.Select(v.id)) }, + modifier = Modifier.size(32.dp) + ) + Text( + text = v.name, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) PrimaryBlue else Color.Unspecified, + modifier = Modifier.weight(2.5f), + ) + Text(v.oepsNummer, fontSize = 13.sp, modifier = Modifier.weight(1.5f)) + Text(v.ort, fontSize = 13.sp, modifier = Modifier.weight(1.5f)) + Text("-", fontSize = 13.sp, modifier = Modifier.weight(1.5f)) + Text("-", fontSize = 13.sp, modifier = Modifier.weight(2f)) + Text( + text = v.loginStatus, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) } } - HorizontalDivider(color = Color(0xFFE5E7EB)) } } @@ -159,51 +165,55 @@ fun VeranstalterAuswahlScreen( Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), + .padding(24.dp), color = Color(0xFFEFF6FF), - shape = MaterialTheme.shapes.small, + shape = MaterialTheme.shapes.medium, border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)), ) { Row( - modifier = Modifier.padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Icon( Icons.Default.Info, contentDescription = null, tint = AccentBlue, - modifier = Modifier.size(16.dp).padding(top = 2.dp), + modifier = Modifier.size(20.dp) ) Text( text = "Veranstalter sind Vereine, die beim österreichischen Pferdesportverband (OEPS) registriert sind. " + "Beim Anlegen eines neuen Veranstalters werden automatisch Login-Daten generiert und per E-Mail verschickt. " + - "Der Veranstalter kann dann sein Profil (Logo, Kontaktdaten, etc.) selbst verwalten.", + "Der Veranstalter kann sein Profil (Logo, Kontaktdaten, etc.) selbst verwalten.", fontSize = 12.sp, + lineHeight = 18.sp, color = Color(0xFF1E40AF), ) } } - HorizontalDivider() - // ── Aktions-Buttons ────────────────────────────────────────────────── - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton(onClick = onZurueck) { - Text("Abbrechen") - } - Spacer(Modifier.width(12.dp)) - Button( - onClick = { state.selectedId?.let { onWeiter(it) } }, - enabled = state.selectedId != null, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + Surface(shadowElevation = 8.dp, color = Color.White) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, ) { - Text("Weiter zum Veranstalter") + TextButton(onClick = onZurueck) { + Text("Abbrechen") + } + Spacer(Modifier.width(16.dp)) + Button( + onClick = { state.selectedId?.let { onWeiter(it) } }, + enabled = state.selectedId != null, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + shape = MaterialTheme.shapes.medium + ) { + Text("Weiter zur Turnier-Konfiguration") + Spacer(Modifier.width(8.dp)) + Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(16.dp)) + } } } } diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterAuswahlV2.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterAuswahlV2.kt deleted file mode 100644 index d86dfdcc..00000000 --- a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/presentation/VeranstalterAuswahlV2.kt +++ /dev/null @@ -1,183 +0,0 @@ -package at.mocode.frontend.features.veranstalter.presentation - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import at.mocode.frontend.core.designsystem.models.LoginStatus -import at.mocode.frontend.core.designsystem.models.LoginStatusBadge - -private val PrimaryBlue = Color(0xFF1E3A8A) - -/** - * Screen: "Admin - Verwaltung / Veranstalter auswählen V2" - * Optimiert für Vision_03 mit verbesserter UI und echtem DDD-Mapping Vorbereitung. - */ -@Composable -fun VeranstalterAuswahlV2( - onZurueck: () -> Unit, - onWeiter: (Long) -> Unit, - onNeuerVeranstalter: () -> Unit = {}, -) { - var selectedId by remember { mutableStateOf(null) } - var suchtext by remember { mutableStateOf("") } - - // Placeholder-Daten gemäß Figma - val veranstalter = remember { - listOf( - VeranstalterUiModel( - 1L, - "Reit- und Fahrverein Wels", - "V-OOE-1234", - "4600 Wels", - "Maria Huber", - "office@rfv-wels.at", - LoginStatus.AKTIV - ), - VeranstalterUiModel( - 2L, - "Pferdesportverein Linz", - "V-OOE-5678", - "4020 Linz", - "Thomas Maier", - "kontakt@psv-linz.at", - LoginStatus.AKTIV - ), - VeranstalterUiModel( - 3L, - "Reitclub Eferding", - "V-OOE-9012", - "4070 Eferding", - "Anna Schmid", - "info@rc-eferding.at", - LoginStatus.AUSSTEHEND - ), - ) - } - - val gefiltert = veranstalter.filter { - suchtext.isBlank() || - it.name.contains(suchtext, ignoreCase = true) || - it.oepsNummer.contains(suchtext, ignoreCase = true) || - it.ort.contains(suchtext, ignoreCase = true) - } - - Column(modifier = Modifier.fillMaxSize().background(Color.White)) { - // Top Bar - Surface(shadowElevation = 4.dp, color = Color.White) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text("Veranstalter auswählen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text( - "Wählen Sie den Verein für die Veranstaltung aus.", - style = MaterialTheme.typography.bodySmall, - color = Color.Gray - ) - } - IconButton(onClick = onZurueck) { - Icon(Icons.Default.Close, contentDescription = "Schließen") - } - } - } - - // Suche & Aktionen - Row( - modifier = Modifier.fillMaxWidth().padding(24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - OutlinedTextField( - value = suchtext, - onValueChange = { suchtext = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Suche nach Name, OEPS-Nummer oder Ort...") }, - leadingIcon = { Icon(Icons.Default.Search, null) }, - singleLine = true - ) - Button( - onClick = onNeuerVeranstalter, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), - shape = MaterialTheme.shapes.medium - ) { - Icon(Icons.Default.Add, null) - Spacer(Modifier.width(8.dp)) - Text("Neuer Veranstalter") - } - } - - // Liste - LazyColumn( - modifier = Modifier.weight(1f).padding(horizontal = 24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(gefiltert) { item -> - val isSelected = selectedId == item.id - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { selectedId = item.id } - .border( - width = 2.dp, - color = if (isSelected) PrimaryBlue else Color.Transparent, - shape = MaterialTheme.shapes.medium - ), - colors = CardDefaults.cardColors( - containerColor = if (isSelected) Color(0xFFEFF6FF) else Color(0xFFF9FAFB) - ) - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton(selected = isSelected, onClick = { selectedId = item.id }) - Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) { - Text(item.name, fontWeight = FontWeight.Bold) - Text("${item.oepsNummer} | ${item.ort}", style = MaterialTheme.typography.bodySmall) - } - LoginStatusBadge(item.loginStatus) - } - } - } - } - - // Footer - Surface(shadowElevation = 8.dp, color = Color.White) { - Row( - modifier = Modifier.fillMaxWidth().padding(24.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onZurueck) { Text("Abbrechen") } - Spacer(Modifier.width(16.dp)) - Button( - onClick = { selectedId?.let { onWeiter(it) } }, - enabled = selectedId != null, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) - ) { - Text("Weiter zur Turnier-Konfiguration") - Spacer(Modifier.width(8.dp)) - Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(16.dp)) - } - } - } - } -} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt index 1b324766..9fc49102 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt @@ -52,12 +52,10 @@ import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScree import at.mocode.frontend.shell.desktop.data.Store import at.mocode.frontend.shell.desktop.data.Turnier import at.mocode.frontend.shell.desktop.data.TurnierStore -import at.mocode.frontend.shell.desktop.screens.management.FunktionaerVerwaltungScreen import at.mocode.frontend.shell.desktop.screens.management.VeranstalterAuswahl import at.mocode.frontend.shell.desktop.screens.management.VeranstalterDetail import at.mocode.frontend.shell.desktop.screens.management.VeranstalterVerwaltungScreen import at.mocode.frontend.shell.desktop.screens.nennung.NennungsEingangScreen -import at.mocode.frontend.shell.desktop.screens.profile.FunktionaerProfil import at.mocode.frontend.shell.desktop.screens.veranstaltung.VeranstaltungVerwaltung import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.TurnierWizard diff --git a/gradle.properties b/gradle.properties index e9d42f39..3b180d2e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -73,7 +73,7 @@ dev.port.offset=0 # ------------------------------------------------------------------ # Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische # Module zu testen. Default=false spart massiv Zeit beim Desktop-Build. -enableWasm=true +enableWasm=false # Dokka Gradle plugin V2 mode (with helpers for V1 compatibility) # See https://kotl.in/dokka-gradle-migration