4 Commits

Author SHA1 Message Date
stefan 5887ac7b6c chore: update roadmap with finalized frontend modernization phase, refactor turnier and veranstalter features to adhere to Plug-and-Play architecture, and improve code hygiene in core frontend modules
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
2026-04-20 10:55:28 +02:00
stefan 8aef46bba1 chore: refactor Veranstalter screens and ViewModels to decouple from Koin, implement hoisting for better testability, consolidate legacy shell logic into modern components, and add mock repositories for previews 2026-04-20 10:31:45 +02:00
stefan 2489beab59 chore: refactor TurnierDetailScreen and related components, remove unused parameters, centralize date validation logic, implement TurnierStammdatenViewModel, and eliminate reflection dependencies 2026-04-20 10:11:10 +02:00
stefan f8820847fa chore: implement Turnier domain logic, add repository interfaces and default implementations, and disable WASM builds 2026-04-20 09:38:39 +02:00
60 changed files with 7755 additions and 598 deletions
+11 -13
View File
@@ -2,12 +2,12 @@
type: Roadmap type: Roadmap
status: ACTIVE status: ACTIVE
owner: Lead Architect owner: Lead Architect
last_update: 2026-04-11 last_update: 2026-04-20
--- ---
# MASTER ROADMAP: Meldestelle # MASTER ROADMAP: Meldestelle
🏗️ **[Lead Architect]** | 11. April 2026 🏗️ **[Lead Architect]** | 20. April 2026
**Strategisches Ziel:** **Strategisches Ziel:**
Entwicklung einer ÖTO-konformen, offline-fähigen Turnier-Meldestelle als Compose Desktop App (KMP). Entwicklung einer ÖTO-konformen, offline-fähigen Turnier-Meldestelle als Compose Desktop App (KMP).
@@ -276,18 +276,16 @@ und über definierte Schnittstellen kommunizieren.
## 4. Geplante Phasen ## 4. Geplante Phasen
### PHASE 13: Export & ZNS-Rückmeldung ✅ ABGESCHLOSSEN (18. April 2026) ### PHASE 13: Frontend-Modernisierung & Cleanup ✅ ABGESCHLOSSEN (20. April 2026)
*Ziel: Finalisierung der Turnier-Daten und Rückübermittlung an den OEPS.* *Ziel: Finalisierung der Turnier-Daten, Rückübermittlung an den OEPS und architektonische Bereinigung.*
* [x] **Mail-Service Integration:** Online-Nennungen via REST/Mail empfangen und persistieren. ✓ (April 2026) * [x] **"V2"-Bereinigung:** Vollständige Eliminierung aller "V2"-Suffixe in Dateinamen und Symbolen (z.B. `TurnierWizardV2`, `VeranstalterAuswahlV2`). ✓ (20. April 2026)
* [x] **Connectivity-Diagnose:** Stabiles Diagnose-Tool für Backend-, DB- und Auth-Verbindung in der Desktop-App. ✓ ( * [x] **Plug-and-Play (Turnier):** Umstellung des `turnier-feature` auf ADR-0024. Entfernung von Reflection-Zugriffen auf die Shell und Einführung von ViewModel-Hoisting. ✓ (20. April 2026)
18. April 2026) * [x] **Plug-and-Play (Veranstalter):** Umstellung des `veranstalter-feature` auf ADR-0024. Einführung des `VeranstalterDetailViewModel` und Konsolidierung der Screens in der Desktop-Shell. ✓ (20. April 2026)
* [x] **Plug-and-Play Architektur:** Umstellung der Frontend-Komponenten auf fachlich autarke Organismen (ADR-0024). * [x] **Code-Hygiene:** Beseitigung von Code-Smells, redundanten Validierungen und ungenutzten Parametern in den zentralen Frontend-Modulen. ✓ (20. April 2026)
✓ (18. April 2026) * [x] **Connectivity-Diagnose:** Stabiles Diagnose-Tool für Backend-, DB- und Auth-Verbindung in der Desktop-App. ✓ (18. April 2026)
* [x] **WASM-Transition:** Projektweite Umstellung auf JVM (Desktop) und wasmJs (Web). Eliminierung von `js(IR)`. ✓ ( * [x] **WASM-Transition:** Projektweite Umstellung auf JVM (Desktop) und wasmJs (Web). Eliminierung von `js(IR)`. ✓ (18. April 2026)
18. April 2026) * [x] **Geräte-Initialisierung:** Refactoring des Onboarding-Prozesses in das Plug-and-Play Modul `device-initialization`. ✓ (18. April 2026)
* [x] **Geräte-Initialisierung:** Refactoring des Onboarding-Prozesses in das Plug-and-Play Modul
`device-initialization`. ✓ (18. April 2026)
* [ ] **XML-Export:** Vollständiger B-Satz Export (inkl. Ergebnisse und Platzierungen). * [ ] **XML-Export:** Vollständiger B-Satz Export (inkl. Ergebnisse und Platzierungen).
* [ ] **ZNS-Portal:** Upload-Integration in das OEPS-ZNS. * [ ] **ZNS-Portal:** Upload-Integration in das OEPS-ZNS.
* [ ] **Archivierung:** Langzeit-Archivierung abgeschlossener Turniere. * [ ] **Archivierung:** Langzeit-Archivierung abgeschlossener Turniere.
@@ -10,6 +10,9 @@ Das Projekt "Meldestelle" strebt eine Multiplattform-Anwendung (KMP) für Deskto
Stabilitätsprobleme im Frontend (der "Kartenhaus-Effekt") wurden durch eng gekoppelte UI-Komponenten und Screens Stabilitätsprobleme im Frontend (der "Kartenhaus-Effekt") wurden durch eng gekoppelte UI-Komponenten und Screens
verursacht. Änderungen in einem Bereich (z.B. Routing) führten zu Zusammenbrüchen in nicht verwandten Features. verursacht. Änderungen in einem Bereich (z.B. Routing) führten zu Zusammenbrüchen in nicht verwandten Features.
Besonders kritisch waren direkte Zugriffe auf Shell-interne Datenstrukturen via Reflection oder direkte Injektion von
ViewModels innerhalb von Composables via `koinInject()`, was die Unabhängigkeit der Feature-Module untergrub.
## Entscheidung ## Entscheidung
Wir führen eine "Plug-and-Play"-Architektur für alle Frontend-Komponenten ein. Diese Strategie stellt sicher, dass Wir führen eine "Plug-and-Play"-Architektur für alle Frontend-Komponenten ein. Diese Strategie stellt sicher, dass
@@ -0,0 +1,37 @@
# Journal-Eintrag: Architektonische Bereinigung turnier-feature (Plug-and-Play)
**Datum:** 20. April 2026
**Agent:** Junie (Lead Architect / Backend Developer)
## 🎯 Zielsetzung
Vollständige Umsetzung der Plug-and-Play Architektur gemäß **ADR-0024** im `turnier-feature`. Dies umfasst die Entfernung von Reflection-Altlasten und die Entkoppelung von Feature-Komponenten von der Shell.
## 🛠 Durchgeführte Änderungen
### 1. Entfernung von Reflection-Altlasten
* **Problem:** `TurnierStammdatenTab.kt` griff via Reflection auf den `TurnierStore` in der Desktop-Shell zu.
* **Lösung:**
* Neues `TurnierStammdatenViewModel` im Feature-Modul erstellt.
* Anbindung an das `TurnierRepository` (Interface-basiert).
* `StammdatenTabContent` nutzt nun dieses ViewModel für State-Management und Persistenz.
### 2. ViewModel-Hoisting im TurnierDetailScreen
* **Problem:** `TurnierDetailScreen` nutzte `koinInject` direkt in der Composable-Struktur, was die Testbarkeit erschwerte und eine harte Abhängigkeit zu Koin innerhalb der UI-Komponente schuf.
* **Lösung:**
* Refactoring von `TurnierDetailScreen`: ViewModels (`BewerbViewModel`, `TurnierNennungViewModel`, `TurnierStammdatenViewModel`) werden nun als Parameter übergeben.
* Die Desktop-Shell (`DesktopMainLayout.kt`) übernimmt die Injektion und Delegation der ViewModels.
### 3. DI-Konfiguration
* **Änderung:** Das `TurnierStammdatenViewModel` wurde im `TurnierFeatureModule.kt` als Factory registriert.
### 4. Code-Hygiene & Previews
* **Änderung:** Die `ScreenPreviews.kt` in der Desktop-Shell wurden aktualisiert, um mit den neuen Parameter-Anforderungen des `TurnierDetailScreen` kompatibel zu sein (Mock-Injektion).
## ✅ Verifizierung
* **Build:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` erfolgreich.
* **Architektur:** Keine direkten Koppelungen von `turnier-feature` zur Shell mehr vorhanden.
## 🧹 Curator-Check
* ADR-0024 Konformität: **Erreicht**.
* V2-Altlasten: **Vollständig entfernt**.
* MASTER_ROADMAP Status: **Aktualisiert**.
@@ -0,0 +1,33 @@
# Journal-Eintrag: Architektur-Cleanup Veranstalter-Feature
**Datum:** 20. April 2026
**Agent:** 🏗️ [Lead Architect] & 👷 [Backend Developer]
## Zielsetzung
Vollständige Umsetzung der Plug-and-Play Architektur (ADR-0024) für das `veranstalter-feature` sowie die Konsolidierung der Screens in der Desktop-Shell.
## Durchgeführte Änderungen
### 1. ViewModel-Hoisting (Entkopplung von Koin)
- **VeranstalterAuswahlScreen**: Der Screen erhält sein ViewModel nun als Parameter. Die direkte Injektion via `koinInject()` wurde entfernt. Dies erhöht die Testbarkeit und Unabhängigkeit der Komponente.
- **VeranstalterDetailScreen**: Ebenfalls auf ViewModel-Hoisting umgestellt.
### 2. Einführung des VeranstalterDetailViewModel
- Ein neues `VeranstalterDetailViewModel` wurde erstellt, um die Logik und den Zustand der Detailansicht zu verwalten.
- Die Abhängigkeit vom veralteten `FakeVeranstaltungStore` wurde durch die Integration in das ViewModel-UDF-Pattern (State/Intent) ersetzt.
- Registrierung der neuen ViewModels (`VeranstalterViewModel`, `VeranstalterDetailViewModel`) im `VeranstalterModule` via Koin `factory`.
### 3. Shell-Konsolidierung (Altlasten-Entfernung)
- Die manuell in der Desktop-Shell (`VeranstalterScreens.kt`) gepflegten Screens wurden durch saubere Delegates ersetzt, welche die modernisierten Komponenten aus dem Feature-Modul einbinden.
- Der direkte Zugriff auf den alten `Store` in der Shell wurde eliminiert.
### 4. Preview-Aktualisierung
- Die `ScreenPreviews.kt` in der Desktop-Shell wurden an die neuen Konstruktoren angepasst.
- Mock-Repositories wurden implementiert, um funktionsfähige Previews ohne aktiven Koin-Container zu ermöglichen.
## Verifizierung
- Erfolgreicher Gradle-Build: `:frontend:shells:meldestelle-desktop:compileKotlinJvm`.
- Manuelle Code-Prüfung auf Einhaltung der ADR-0024 Kriterien.
## Status
🟢 **Abgeschlossen.** Das Veranstalter-Management ist nun vollständig architektonisch bereinigt und folgt dem Plug-and-Play Design.
@@ -0,0 +1,33 @@
# Journal: Code-Cleanup & Smell-Entfernung
**Datum:** 20. April 2026
**Agent:** 🧐 [QA Specialist] & 🏗️ [Lead Architect]
## 🎯 Ziel
Beseitigung von Code-Smells, ungenutzten Parametern und Code-Duplikaten in den kürzlich refactorten Turnier-Komponenten.
## 🛠️ Durchgeführte Änderungen
### 1. TurnierDetailScreen & Shell-Integration
- **Problem:** Parameter `onBack` in `TurnierDetailScreen` wurde nicht verwendet.
- **Lösung:** Parameter entfernt und alle Aufrufstellen in `DesktopMainLayout.kt` sowie `ScreenPreviews.kt` angepasst.
- **Grund:** Leaner Code-Design und Vermeidung von Verwirrung bei der API-Nutzung.
### 2. DesktopMainLayout (Navigation)
- **Problem:** Der Zweig `is AppScreen.Vereine` war redundant und teilweise nicht erreichbar.
- **Lösung:** Redundanten Zweig entfernt. Die Navigation zu Vereinen wird bereits weiter oben im `when`-Block (Z. 668) abgehandelt.
### 3. TurnierStammdatenTab (Refactoring)
- **Problem:** Ungenutzter Parameter `veranstalterName`. Mehrfache Code-Duplikate bei der Datumsvalidierung und den DatePicker-Dialogen.
- **Lösung:**
- Parameter `veranstalterName` entfernt.
- Neue Hilfsfunktion `isDateRangeValid(von, bis, eventVon, eventBis)` erstellt, um die Validierungslogik zu zentralisieren.
- Neue Composable-Funktion `TurnierDatePickerDialog` erstellt, um die redundante Dialog-Struktur zu eliminieren.
- **Ergebnis:** Reduzierung der Dateigröße und deutlich bessere Wartbarkeit.
## ✅ Verifikation
- **Build:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` war erfolgreich.
- **Code-Check:** Manuelle Prüfung der bereinigten Stellen auf Konsistenz.
---
*Status: Abgeschlossen. Codebase ist nun sauber für die weitere Feature-Entwicklung.*
@@ -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)*
@@ -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)
@@ -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<RichterEinsatzDto> = 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<RichterEinsatzDto> = 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()
}
@@ -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<List<Abteilung>> = runCatching {
val response = client.get(ApiRoutes.Bewerbe.abteilungen(bewerbId))
when {
response.status.isSuccess() -> response.body<List<AbteilungDto>>().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<Abteilung> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/abteilungen/$id")
when {
response.status.isSuccess() -> response.body<AbteilungDto>().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<Abteilung> = runCatching {
val response = client.post(ApiRoutes.Bewerbe.abteilungen(model.bewerbId)) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<AbteilungDto>().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<Abteilung> = runCatching {
val response = client.put("${ApiRoutes.API_PREFIX}/abteilungen/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<AbteilungDto>().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<Unit> = 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)
}
}
}
@@ -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<List<Bewerb>> = runCatching {
val response = client.get(ApiRoutes.Turniere.bewerbe(turnierId))
when {
response.status.isSuccess() -> response.body<List<BewerbDto>>().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<at.mocode.zns.parser.ZnsBewerb>): Result<Unit> =
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<Bewerb> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id")
when {
response.status.isSuccess() -> response.body<BewerbDto>().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<Bewerb> = runCatching {
val response = client.post(ApiRoutes.Turniere.bewerbe(model.turnierId)) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<BewerbDto>().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<Bewerb> = runCatching {
val response = client.put("${ApiRoutes.API_PREFIX}/bewerbe/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<BewerbDto>().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<Bewerb> =
runCatching {
val response = client.patch("${ApiRoutes.API_PREFIX}/bewerbe/$id/zeitplan") {
contentType(ContentType.Application.Json)
setBody(
mapOf(
"geplantesDatum" to datum,
"beginnZeit" to beginn,
"austragungsplatzId" to platzId
)
)
}
when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/audit-log")
when {
response.status.isSuccess() -> response.body<List<AuditLogEntry>>()
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<String> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/turniere/$turnierId/export/zns/b-satz")
when {
response.status.isSuccess() -> response.body<String>()
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<Unit> = 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)
}
}
}
@@ -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<List<Ergebnis>> = runCatching {
client.get(ApiRoutes.Results.bewerb(bewerbId)).body()
}
override suspend fun save(ergebnis: Ergebnis): Result<Ergebnis> = runCatching {
if (ergebnis.id == null) {
client.post(ApiRoutes.Results.ROOT) {
contentType(ContentType.Application.Json)
setBody(ergebnis)
}.body()
} else {
client.put("${ApiRoutes.Results.ROOT}/${ergebnis.id}") {
contentType(ContentType.Application.Json)
setBody(ergebnis)
}.body()
}
}
override suspend fun calculatePlatzierung(bewerbId: String): Result<List<Ergebnis>> = runCatching {
client.post("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/calculate").body()
}
override suspend fun exportPdf(bewerbId: String): Result<ByteArray> = runCatching {
client.get("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/pdf").body()
}
}
@@ -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<List<Reiter>> = 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<List<ReiterApiDto>>().map { it.toDomain() }
} else emptyList()
}
override suspend fun getReiter(id: String): Result<Reiter> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.REITER}/$id")
if (response.status.isSuccess()) {
response.body<ReiterApiDto>().toDomain()
} else throw Exception("Reiter nicht gefunden")
}
override suspend fun saveReiter(reiter: Reiter): Result<Reiter> = 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<ReiterApiDto>().toDomain()
} else throw Exception("Fehler beim Speichern des Reiters")
}
override suspend fun searchPferde(query: String): Result<List<Pferd>> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.PFERDE}/search") {
parameter("q", query)
}
if (response.status.isSuccess()) {
response.body<List<HorseApiDto>>().map { it.toDomain() }
} else emptyList()
}
override suspend fun getPferd(id: String): Result<Pferd> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.PFERDE}/$id")
if (response.status.isSuccess()) {
response.body<HorseApiDto>().toDomain()
} else throw Exception("Pferd nicht gefunden")
}
override suspend fun savePferd(pferd: Pferd): Result<Pferd> = 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<HorseApiDto>().toDomain()
} else throw Exception("Fehler beim Speichern des Pferdes")
}
override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.FUNKTIONAERE}/search") {
parameter("q", query)
}
if (response.status.isSuccess()) {
response.body<List<FunktionaerApiDto>>().map {
Funktionaer(it.funktionaerId, it.name ?: "Unbekannt", it.qualifikationen, it.istAktiv)
}
} else emptyList()
}
override suspend fun listVereine(): Result<List<Verein>> = runCatching {
val response = client.get(ApiRoutes.Masterdata.VEREINE)
if (response.status.isSuccess()) {
response.body<List<VereinApiDto>>().map {
Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter)
}
} else emptyList()
}
override suspend fun getVereinById(id: String): Result<Verein> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.VEREINE}/$id")
if (response.status.isSuccess()) {
val it = response.body<VereinApiDto>()
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<String> = 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
)
}
@@ -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<List<Nennung>> = runCatching {
val response = client.get("${ApiRoutes.Turniere.ROOT}/$turnierId/nennungen")
if (response.status.isSuccess()) {
response.body<List<NennungSummaryDto>>().map { it.toDomain() }
} else {
throw HttpError(response.status.value)
}
}
override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = runCatching {
val response = client.get(ApiRoutes.Bewerbe.nennungen(bewerbId))
if (response.status.isSuccess()) {
response.body<List<NennungSummaryDto>>().map { it.toDomain() }
} else {
throw HttpError(response.status.value)
}
}
override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> = runCatching {
val response = client.post(ApiRoutes.Bewerbe.nennungen(request.bewerbId.toLong())) {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
response.body<NennungDetailDto>().toDomain()
} else {
throw HttpError(response.status.value)
}
}
override suspend fun updateStatus(id: String, status: String): Result<Nennung> = 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<NennungDetailDto>().toDomain()
} else {
throw HttpError(response.status.value)
}
}
override suspend fun delete(id: String): Result<Unit> = 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
)
}
@@ -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<List<Serie>> = runCatching {
client.get(ApiRoutes.Series.ROOT).body()
}
override suspend fun getById(id: String): Result<Serie> = runCatching {
client.get("${ApiRoutes.Series.ROOT}/$id").body()
}
override suspend fun save(serie: Serie): Result<Serie> = 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<List<SerieStandEntry>> = runCatching {
client.get(ApiRoutes.Series.stand(serieId)).body()
}
}
@@ -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<List<StartlistenZeile>> = 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<List<StartlistenZeile>> = 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)
}
}
}
@@ -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<List<Turnier>> = runCatching {
val response = client.get(ApiRoutes.Turniere.ROOT)
when {
response.status.isSuccess() -> response.body<List<TurnierDto>>().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<Turnier> = runCatching {
val response = client.get("${ApiRoutes.Turniere.ROOT}/$id")
when {
response.status.isSuccess() -> response.body<TurnierDto>().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<Turnier> = runCatching {
val response = client.post(ApiRoutes.Turniere.ROOT) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<TurnierDto>().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<Turnier> = runCatching {
val response = client.put("${ApiRoutes.Turniere.ROOT}/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<TurnierDto>().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<Unit> = 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)
}
}
}
@@ -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
)
@@ -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<AbteilungsWarnungDto> = 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,
)
@@ -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<List<Abteilung>>
suspend fun getById(id: Long): Result<Abteilung>
suspend fun create(model: Abteilung): Result<Abteilung>
suspend fun update(id: Long, model: Abteilung): Result<Abteilung>
suspend fun delete(id: Long): Result<Unit>
}
@@ -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<AbteilungsWarnung> = emptyList(),
// Zeitplan-Felder
val geplantesDatum: String? = null, // ISO-Format
val beginnZeit: String? = null, // "HH:mm"
val reitdauerMinuten: Int? = null,
val umbauMinuten: Int? = null,
val besichtigungMinuten: Int? = null,
val austragungsplatzId: String? = null,
)
data class AbteilungsWarnung(
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<List<Bewerb>>
suspend fun getById(id: Long): Result<Bewerb>
suspend fun create(model: Bewerb): Result<Bewerb>
suspend fun update(id: Long, model: Bewerb): Result<Bewerb>
suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb>
suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>>
suspend fun exportZnsBSatz(turnierId: Long): Result<String>
suspend fun delete(id: Long): Result<Unit>
suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit>
}
@@ -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<List<Ergebnis>>
suspend fun save(ergebnis: Ergebnis): Result<Ergebnis>
suspend fun calculatePlatzierung(bewerbId: String): Result<List<Ergebnis>>
suspend fun exportPdf(bewerbId: String): Result<ByteArray>
}
@@ -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<String>,
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<List<Reiter>>
suspend fun getReiter(id: String): Result<Reiter>
suspend fun saveReiter(reiter: Reiter): Result<Reiter>
suspend fun searchPferde(query: String): Result<List<Pferd>>
suspend fun getPferd(id: String): Result<Pferd>
suspend fun savePferd(pferd: Pferd): Result<Pferd>
suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>>
suspend fun listVereine(): Result<List<Verein>>
suspend fun getVereinById(id: String): Result<Verein>
}
@@ -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<List<Nennung>>
suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>>
suspend fun einreichen(request: at.mocode.frontend.features.turnier.data.remote.dto.NennungEinreichenRequest): Result<Nennung>
suspend fun updateStatus(id: String, status: String): Result<Nennung>
suspend fun delete(id: String): Result<Unit>
}
@@ -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<String> = 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<List<Serie>>
suspend fun getById(id: String): Result<Serie>
suspend fun save(serie: Serie): Result<Serie>
suspend fun getStand(serieId: String): Result<List<SerieStandEntry>>
}
@@ -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<List<StartlistenZeile>>
suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>>
}
@@ -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<List<Turnier>>
suspend fun getById(id: Long): Result<Turnier>
suspend fun create(model: Turnier): Result<Turnier>
suspend fun update(id: Long, model: Turnier): Result<Turnier>
suspend fun delete(id: Long): Result<Unit>
}
@@ -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 = ""
)
@@ -23,6 +23,7 @@ actual val turnierFeatureModule = module {
// ViewModels // ViewModels
factory { TurnierViewModel(repo = get()) } factory { TurnierViewModel(repo = get()) }
factory { TurnierStammdatenViewModel(repo = get()) }
// BewerbViewModel: repos + syncManager + turnierId // BewerbViewModel: repos + syncManager + turnierId
factory { (turnierId: Long) -> factory { (turnierId: Long) ->
BewerbViewModel( BewerbViewModel(
@@ -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<StartlistenEintrag> = emptyList(),
val ergebnisse: List<ErgebnisEintrag> = 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<StartlistenEintrag>
suspend fun loadErgebnisse(bewerbId: Long, abteilungsNr: Int): List<ErgebnisEintrag>
suspend fun saveErgebnis(bewerbId: Long, abteilungsNr: Int, startNr: Int, punkte: Double?)
suspend fun saveStartlistenOrder(bewerbId: Long, abteilungsNr: Int, orderedStartNr: List<Int>)
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<AbteilungState> = _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)
}
}
@@ -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+).",
)
}
}
@@ -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<AbteilungsInput> = 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<BewerbAnlegenState> = _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)
}
}
@@ -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<BewerbListItem> = emptyList(),
val filtered: List<BewerbListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
val importPreview: List<ZnsBewerb> = emptyList(),
val nennungenPreview: List<ZnsNennung> = emptyList(),
val showImportDialog: Boolean = false,
val showStartlistePreview: Boolean = false,
val currentStartliste: List<StartlistenZeile> = emptyList(),
val discoveredNodes: List<DiscoveredService> = emptyList(),
val isScanning: Boolean = false,
// Zeitplan-Audit
val auditLog: List<at.mocode.frontend.features.turnier.domain.AuditLogEntry> = emptyList(),
val isAuditLoading: Boolean = false,
val exportContent: String? = null,
val showExportDialog: Boolean = false,
val ergebnisse: List<at.mocode.frontend.features.turnier.domain.Ergebnis> = 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<String>) : 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<BewerbState> = _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<BewerbListItem>, query: String): List<BewerbListItem> {
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)
}
}
@@ -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<RichterEinsatzDto> = 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,
)
}
@@ -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")
}
}
)
}
@@ -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")
}
}
}
}
@@ -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<Serie> = emptyList(),
val isLoading: Boolean = false,
val selectedSerieStand: List<SerieStandEntry> = 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()
}
}
}
}
@@ -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<BuchungspositionUiModel>) {
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)
@@ -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)
@@ -0,0 +1,123 @@
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
/**
* 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,
bewerbViewModel: BewerbViewModel,
nennungViewModel: TurnierNennungViewModel,
stammdatenViewModel: TurnierStammdatenViewModel,
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) }
val tabs = listOf(
"STAMMDATEN",
"ORGANISATION",
"BEWERBE",
"ARTIKEL",
"ABRECHNUNG",
"NENNUNGEN",
"ONLINE-EINGANG",
"ZEITPLAN",
"STARTLISTEN",
"ERGEBNISLISTEN",
)
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,
viewModel = stammdatenViewModel,
eventVon = eventVon,
eventBis = eventBis,
eventOrt = eventOrt,
veranstalterOrt = veranstalterOrt,
veranstalterBundesland = veranstalterBundesland,
veranstalterLogoUrl = veranstalterLogoUrl,
)
1 -> OrganisationTabContent(viewModel = nennungViewModel)
2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId)
3 -> ArtikelTabContent()
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
5 -> NennungenTabContent(
viewModel = nennungViewModel,
onAbrechnungClick = { selectedTab = 4 }
)
6 -> 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()
@@ -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<BewerbListItem>,
selectedId: Long?,
onSelect: (Long?) -> Unit,
ergebnisse: List<Ergebnis>,
startliste: List<StartlistenZeile>,
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<Ergebnis> = 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)
}
}
}
}
@@ -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<OnlineNennung> = emptyList(),
val isOnlineLoading: Boolean = false
)
data class NennungenState(
val isLoading: Boolean = false,
val nennungen: List<Nennung> = emptyList(),
val searchResultsReiter: List<Reiter> = emptyList(),
val searchResultsPferde: List<Pferd> = emptyList(),
val searchResultsFunktionaere: List<Funktionaer> = 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<NennungenState> = _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)
}
}
}
}
@@ -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,
)
@@ -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
)
}
}
}
@@ -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")
}
}
}
}
}
}
@@ -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)
@@ -0,0 +1,583 @@
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,
viewModel: TurnierStammdatenViewModel,
eventVon: String? = null,
eventBis: String? = null,
eventOrt: String? = null,
veranstalterOrt: String? = null,
veranstalterBundesland: String? = null,
veranstalterLogoUrl: String? = null,
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(turnierId) {
viewModel.send(TurnierStammdatenIntent.Load(turnierId))
}
var turnierNr by remember(state.turnierNr) { mutableStateOf(state.turnierNr) }
var nrConfirmed by remember(state.turnierNr) { mutableStateOf(state.turnierNr.isNotEmpty()) }
var showNrConfirm by remember { mutableStateOf(false) }
var znsDataLoaded by remember(state.znsDataLoaded) { mutableStateOf(state.znsDataLoaded) }
var znsPayloadVersion by remember { mutableStateOf<String?>(null) }
var znsImportedAt by remember { mutableStateOf<String?>(null) }
val znsImportHistory =
remember { mutableStateListOf<Triple<String, String, Boolean>>() } // (source, payloadVersion, ok)
var typ by remember(state.typ) { mutableStateOf(state.typ) }
val sparten = remember { mutableStateListOf<String>() }
val klassen = remember { mutableStateListOf<String>() }
val kat = remember(state.kategorie) {
mutableStateListOf<String>().apply { addAll(state.kategorie) }
}
var von by remember(state.von, eventVon) { mutableStateOf(state.von.ifEmpty { eventVon ?: "" }) }
var bis by remember(state.bis, eventBis) { mutableStateOf(state.bis.ifEmpty { eventBis ?: "" }) }
var ort by remember(state.ort, eventOrt) { mutableStateOf(state.ort.ifEmpty { eventOrt ?: "" }) }
var titel by remember(state.titel) { mutableStateOf(state.titel) }
var subTitel by remember(state.subTitel) { mutableStateOf(state.subTitel) }
var turnierLogoUrl by remember { mutableStateOf("") }
val sponsoren = remember { mutableStateListOf<String>() }
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<String>()
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) {
isDateRangeValid(von, bis, eventVon, eventBis)
}
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 = {
viewModel.send(TurnierStammdatenIntent.UpdateTitel(titel))
viewModel.send(TurnierStammdatenIntent.UpdateSubTitel(subTitel))
viewModel.send(TurnierStammdatenIntent.Save)
},
enabled = run {
val base = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank()
val dateValid = isDateRangeValid(von, bis, eventVon, eventBis)
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 -> {
TurnierDatePickerDialog(
onDismiss = { showDatePickerVon = false },
onDateSelected = { von = it }
)
}
showDatePickerBis -> {
TurnierDatePickerDialog(
onDismiss = { showDatePickerBis = false },
onDateSelected = { bis = it }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TurnierDatePickerDialog(
onDismiss: () -> Unit,
onDateSelected: (String) -> Unit
) {
val state = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let {
onDateSelected(LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString())
}
onDismiss()
}) { Text("OK") }
}
) { DatePicker(state) }
}
private fun isDateRangeValid(von: String, bis: String, eventVon: String?, eventBis: String?): Boolean {
return 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
}
}
@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()
}
}
}
@@ -0,0 +1,92 @@
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
data class TurnierStammdatenState(
val isLoading: Boolean = false,
val turnier: Turnier? = null,
val error: String? = null,
// Diese Felder bilden den UI-State für die Bearbeitungsmaske
val turnierNr: String = "",
val titel: String = "",
val subTitel: String = "",
val typ: String = "ÖTO (National)",
val kategorie: List<String> = emptyList(),
val von: String = "",
val bis: String = "",
val ort: String = "",
val znsDataLoaded: Boolean = false
)
sealed interface TurnierStammdatenIntent {
data class Load(val id: Long) : TurnierStammdatenIntent
data class UpdateTitel(val titel: String) : TurnierStammdatenIntent
data class UpdateSubTitel(val subTitel: String) : TurnierStammdatenIntent
data object Save : TurnierStammdatenIntent
}
class TurnierStammdatenViewModel(
private val repo: TurnierRepository
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(TurnierStammdatenState())
val state: StateFlow<TurnierStammdatenState> = _state
fun send(intent: TurnierStammdatenIntent) {
when (intent) {
is TurnierStammdatenIntent.Load -> load(intent.id)
is TurnierStammdatenIntent.UpdateTitel -> reduce { it.copy(titel = intent.titel) }
is TurnierStammdatenIntent.UpdateSubTitel -> reduce { it.copy(subTitel = intent.subTitel) }
is TurnierStammdatenIntent.Save -> save()
}
}
private fun load(id: Long) {
reduce { it.copy(isLoading = true, error = null) }
scope.launch {
repo.getById(id)
.onSuccess { t ->
reduce {
it.copy(
isLoading = false,
turnier = t,
turnierNr = t.id.toString(),
titel = t.name,
// Weitere Felder müssten im Domänenmodell ergänzt werden.
// Für den Moment simulieren wir die Daten, die vorher per Reflection geladen wurden
subTitel = "Internationales Springturnier",
typ = "ÖTO (National)",
kategorie = listOf("CSN-B*"),
von = "2026-05-01",
bis = "2026-05-03",
ort = "Stadl-Paura",
znsDataLoaded = true
)
}
}
.onFailure { err ->
reduce { it.copy(isLoading = false, error = err.message) }
}
}
}
private fun save() {
val current = _state.value
val t = current.turnier ?: return
scope.launch {
repo.update(t.id, t.copy(name = current.titel))
.onSuccess { /* Feedback? */ }
}
}
private inline fun reduce(block: (TurnierStammdatenState) -> TurnierStammdatenState) {
_state.value = block(_state.value)
}
}
@@ -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<Bewerb>,
selectedId: Long?,
onSelect: (Long?) -> Unit,
currentStartliste: List<StartlistenZeile>,
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))
}
}
@@ -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<TurnierListItem> = emptyList(),
val filtered: List<TurnierListItem> = 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<TurnierState> = _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<TurnierListItem>, query: String): List<TurnierListItem> {
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)
}
}
@@ -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)),
)
@@ -2,8 +2,12 @@ package at.mocode.frontend.features.veranstalter.di
import at.mocode.frontend.features.veranstalter.data.remote.FakeVeranstalterRepository import at.mocode.frontend.features.veranstalter.data.remote.FakeVeranstalterRepository
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailViewModel
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterViewModel
import org.koin.dsl.module import org.koin.dsl.module
val veranstalterModule = module { val veranstalterModule = module {
single<VeranstalterRepository> { FakeVeranstalterRepository() } single<VeranstalterRepository> { FakeVeranstalterRepository() }
factory { VeranstalterViewModel(get()) }
factory { VeranstalterDetailViewModel(get()) }
} }
@@ -1,20 +1,21 @@
package at.mocode.frontend.features.veranstalter.presentation package at.mocode.frontend.features.veranstalter.presentation
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -27,131 +28,137 @@ private val AccentBlue = Color(0xFF3B82F6)
/** /**
* Screen: "Admin - Verwaltung / Veranstalter auswählen" * Screen: "Admin - Verwaltung / Veranstalter auswählen"
* * Optimiert für Vision_03 mit dichter Tabellenansicht für Profis.
* 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).
*/ */
@Composable @Composable
fun VeranstalterAuswahlScreen( fun VeranstalterAuswahlScreen(
viewModel: VeranstalterViewModel,
onZurueck: () -> Unit, onZurueck: () -> Unit,
onWeiter: (Long) -> Unit, onWeiter: (Long) -> Unit,
onNeuerVeranstalter: () -> 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() val state by viewModel.state.collectAsState()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
// ── Titel ──────────────────────────────────────────────────────────── // ── Titel ────────────────────────────────────────────────────────────
Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { Surface(shadowElevation = 4.dp, color = Color.White) {
Text( Row(
text = "Veranstalter für neue Veranstaltung auswählen", modifier = Modifier.fillMaxWidth().padding(16.dp),
fontSize = 20.sp, verticalAlignment = Alignment.CenterVertically,
fontWeight = FontWeight.Bold, horizontalArrangement = Arrangement.SpaceBetween
) ) {
Text( Column {
text = "Wählen Sie einen bestehenden Veranstalter aus oder legen Sie einen neuen Veranstalter an.", Text(
fontSize = 13.sp, text = "Veranstalter auswählen",
color = Color(0xFF6B7280), 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 ──────────────────────────────────── // ── Suchfeld + Neuer Veranstalter ────────────────────────────────────
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp), .padding(24.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
OutlinedTextField( OutlinedTextField(
value = state.searchQuery, value = state.searchQuery,
onValueChange = { viewModel.send(VeranstalterIntent.SearchChanged(it)) }, 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) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier.weight(1f).height(48.dp), modifier = Modifier.weight(1f),
singleLine = true, singleLine = true,
) )
Button( Button(
onClick = onNeuerVeranstalter, onClick = onNeuerVeranstalter,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
shape = MaterialTheme.shapes.medium
) { ) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(8.dp))
Text("Neuer Veranstalter") Text("Neuer Veranstalter")
} }
} }
// ── Tabellen-Header ────────────────────────────────────────────────── // ── Tabellen-Header ──────────────────────────────────────────────────
Row( Surface(
modifier = Modifier color = Color(0xFFF9FAFB),
.fillMaxWidth() modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)
.background(Color(0xFFF3F4F6))
.padding(horizontal = 24.dp, vertical = 8.dp),
) { ) {
Spacer(Modifier.width(28.dp)) // Checkmark-Spalte Row(
Text("Vereinsname", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(2.5f)) modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
Text("OEPS-Nummer", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f)) verticalAlignment = Alignment.CenterVertically
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)) Spacer(Modifier.width(32.dp)) // Platz für Checkmark/Radio
Text("E-Mail", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(2f)) Text("Vereinsname", fontWeight = FontWeight.Bold, fontSize = 12.sp, modifier = Modifier.weight(2.5f))
Text("Login", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1f)) 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 ────────────────────────────────────────────────── // ── 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 -> items(state.filtered) { v ->
val isSelected = v.id == state.selectedId val isSelected = v.id == state.selectedId
Row( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(
if (isSelected) AccentBlue.copy(alpha = 0.08f)
else Color.Transparent,
)
.clickable { viewModel.send(VeranstalterIntent.Select(v.id)) } .clickable { viewModel.send(VeranstalterIntent.Select(v.id)) }
.padding(horizontal = 24.dp, vertical = 10.dp), .border(
verticalAlignment = Alignment.CenterVertically, width = 1.dp,
) { color = if (isSelected) PrimaryBlue else Color(0xFFE5E7EB),
// Auswahl-Checkmark shape = MaterialTheme.shapes.small
Box(modifier = Modifier.width(28.dp)) { ),
if (isSelected) { colors = CardDefaults.cardColors(
Icon( containerColor = if (isSelected) Color(0xFFEFF6FF) else Color.White
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),
) )
Text(v.oepsNummer, fontSize = 13.sp, modifier = Modifier.weight(1.5f)) ) {
Text(v.ort, fontSize = 13.sp, modifier = Modifier.weight(1.5f)) Row(
// Placeholder für Ansprechpartner/E-Mail vorerst leer im ListItem-Model modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
Text("-", fontSize = 13.sp, modifier = Modifier.weight(1.5f)) verticalAlignment = Alignment.CenterVertically
Text("-", fontSize = 13.sp, modifier = Modifier.weight(2f)) ) {
// Login-Status-Badge RadioButton(
Box(modifier = Modifier.weight(1f)) { selected = isSelected,
// Für die Referenz reicht String-Label onClick = { viewModel.send(VeranstalterIntent.Select(v.id)) },
Text(v.loginStatus, fontSize = 12.sp, color = Color(0xFF111827)) 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 +166,55 @@ fun VeranstalterAuswahlScreen(
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp), .padding(24.dp),
color = Color(0xFFEFF6FF), color = Color(0xFFEFF6FF),
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.medium,
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)), border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)),
) { ) {
Row( Row(
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Icon( Icon(
Icons.Default.Info, Icons.Default.Info,
contentDescription = null, contentDescription = null,
tint = AccentBlue, tint = AccentBlue,
modifier = Modifier.size(16.dp).padding(top = 2.dp), modifier = Modifier.size(20.dp)
) )
Text( Text(
text = "Veranstalter sind Vereine, die beim österreichischen Pferdesportverband (OEPS) registriert sind. " + 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. " + "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, fontSize = 12.sp,
lineHeight = 18.sp,
color = Color(0xFF1E40AF), color = Color(0xFF1E40AF),
) )
} }
} }
HorizontalDivider()
// ── Aktions-Buttons ────────────────────────────────────────────────── // ── Aktions-Buttons ──────────────────────────────────────────────────
Row( Surface(shadowElevation = 8.dp, color = Color.White) {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.padding(horizontal = 24.dp, vertical = 12.dp), .fillMaxWidth()
horizontalArrangement = Arrangement.End, .padding(24.dp),
verticalAlignment = Alignment.CenterVertically, 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),
) { ) {
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))
}
} }
} }
} }
@@ -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<Long?>(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))
}
}
}
}
}
@@ -7,13 +7,16 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -44,85 +47,20 @@ private val StatusAbgeschlossenColor = Color(0xFF6B7280)
@Composable @Composable
fun VeranstalterDetailScreen( fun VeranstalterDetailScreen(
veranstalterId: Long, veranstalterId: Long,
viewModel: VeranstalterDetailViewModel,
onZurueck: () -> Unit, onZurueck: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit, onVeranstaltungOeffnen: (Long) -> Unit,
onVeranstaltungNeu: () -> Unit, onVeranstaltungNeu: () -> Unit,
) { ) {
var suchtext by remember { mutableStateOf("") } val state by viewModel.state.collectAsState()
var statusFilter by remember { mutableStateOf(VeranstaltungStatusFilter.ALLE) }
// Placeholder-Daten gemäß Figma
val veranstalter = remember(veranstalterId) {
VeranstalterDetailUiModel(
id = veranstalterId,
name = "Reit- und Fahrverein Wels",
oepsNummer = "V-OOE-1234",
ansprechpartner = "Maria Huber",
email = "office@rfv-wels.at",
telefon = "+43 7242 12345",
adresse = "Reitweg 15\n4600 Wels",
loginStatus = LoginStatus.AKTIV,
mitgliedSeit = "15.1.2023",
)
}
// Liste aus dem Fake-Store (pro Veranstalter). Falls leer, einmalig seeden.
val storeList = FakeVeranstaltungStore.listFor(veranstalterId)
LaunchedEffect(veranstalterId) { LaunchedEffect(veranstalterId) {
if (storeList.isEmpty()) { viewModel.send(VeranstalterDetailIntent.Load(veranstalterId))
FakeVeranstaltungStore.seedIfEmpty(
veranstalterId,
listOf(
VeranstaltungListUiModel(
id = 1L,
name = "Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026",
datum = "25.-26. April 2026",
ort = "Reitanlage Stroblmair, Neumarkt/M., OO",
turnierAnzahl = 2,
nennungen = 87,
bewerbe = 26,
letzteAktivitaet = "22.03.2026 14:30",
status = VeranstaltungStatus.VORBEREITUNG,
),
VeranstaltungListUiModel(
id = 2L,
name = "AWÖ-Cup Stadl-Paura 2025",
datum = "15.-17. Mai 2025",
ort = "Bundesgestüt Piber, Stadl-Paura",
turnierAnzahl = 2,
nennungen = 142,
bewerbe = 33,
letzteAktivitaet = "17.05.2025 18:45",
status = VeranstaltungStatus.ABGESCHLOSSEN,
),
VeranstaltungListUiModel(
id = 3L,
name = "Linzer Pferdetage 2026",
datum = "12.-14. Juni 2026",
ort = "Reitsportzentrum Linz-Ebelsberg",
turnierAnzahl = 2,
nennungen = 23,
bewerbe = 30,
letzteAktivitaet = "20.03.2026 09:15",
status = VeranstaltungStatus.VORBEREITUNG,
),
)
)
}
} }
val gefiltert = storeList.filter { v -> val veranstalter = state.veranstalter ?: return // Oder Loading-Spinner
val matchesStatus = when (statusFilter) {
VeranstaltungStatusFilter.ALLE -> true val gefiltert = state.filteredVeranstaltungen
VeranstaltungStatusFilter.VORBEREITUNG -> v.status == VeranstaltungStatus.VORBEREITUNG
VeranstaltungStatusFilter.LIVE -> v.status == VeranstaltungStatus.LIVE
VeranstaltungStatusFilter.ABGESCHLOSSEN -> v.status == VeranstaltungStatus.ABGESCHLOSSEN
}
val matchesSuche = suchtext.isBlank() ||
v.name.contains(suchtext, ignoreCase = true) ||
v.ort.contains(suchtext, ignoreCase = true)
matchesStatus && matchesSuche
}
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// ── Header mit Zurück-Pfeil ───────────────────────────────────────── // ── Header mit Zurück-Pfeil ─────────────────────────────────────────
@@ -223,8 +161,8 @@ fun VeranstalterDetailScreen(
Text("Neue Veranstaltung") Text("Neue Veranstaltung")
} }
OutlinedTextField( OutlinedTextField(
value = suchtext, value = state.suchtext,
onValueChange = { suchtext = it }, onValueChange = { viewModel.send(VeranstalterDetailIntent.SearchChanged(it)) },
placeholder = { Text("Suche nach Name, Ort oder Turnier-Nr.", fontSize = 12.sp) }, placeholder = { Text("Suche nach Name, Ort oder Turnier-Nr.", fontSize = 12.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
modifier = Modifier.weight(1f).height(44.dp), modifier = Modifier.weight(1f).height(44.dp),
@@ -233,10 +171,10 @@ fun VeranstalterDetailScreen(
// Status-Filter-Chips // Status-Filter-Chips
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
VeranstaltungStatusFilter.entries.forEach { filter -> VeranstaltungStatusFilter.entries.forEach { filter ->
val isActive = statusFilter == filter val isActive = state.statusFilter == filter
FilterChip( FilterChip(
selected = isActive, selected = isActive,
onClick = { statusFilter = filter }, onClick = { viewModel.send(VeranstalterDetailIntent.FilterChanged(filter)) },
label = { label = {
Text( Text(
text = when (filter) { text = when (filter) {
@@ -268,7 +206,7 @@ fun VeranstalterDetailScreen(
veranstaltung = veranstaltung, veranstaltung = veranstaltung,
onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) }, onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) },
onLoeschen = { onLoeschen = {
FakeVeranstaltungStore.remove(veranstalterId, veranstaltung.id) viewModel.send(VeranstalterDetailIntent.DeleteVeranstaltung(veranstaltung.id))
} }
) )
} }
@@ -0,0 +1,138 @@
package at.mocode.frontend.features.veranstalter.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
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.core.designsystem.models.VeranstaltungStatus
import at.mocode.frontend.core.designsystem.models.LoginStatus
data class VeranstalterDetailState(
val isLoading: Boolean = false,
val veranstalter: VeranstalterDetailUiModel? = null,
val veranstaltungen: List<VeranstaltungListUiModel> = emptyList(),
val filteredVeranstaltungen: List<VeranstaltungListUiModel> = emptyList(),
val suchtext: String = "",
val statusFilter: VeranstaltungStatusFilter = VeranstaltungStatusFilter.ALLE,
val errorMessage: String? = null,
)
sealed interface VeranstalterDetailIntent {
data class Load(val id: Long) : VeranstalterDetailIntent
data class SearchChanged(val query: String) : VeranstalterDetailIntent
data class FilterChanged(val filter: VeranstaltungStatusFilter) : VeranstalterDetailIntent
data class DeleteVeranstaltung(val id: Long) : VeranstalterDetailIntent
}
class VeranstalterDetailViewModel(
private val repo: VeranstalterRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(VeranstalterDetailState(isLoading = true))
val state: StateFlow<VeranstalterDetailState> = _state
fun send(intent: VeranstalterDetailIntent) {
when (intent) {
is VeranstalterDetailIntent.Load -> load(intent.id)
is VeranstalterDetailIntent.SearchChanged -> {
_state.value = _state.value.copy(suchtext = intent.query)
applyFilter()
}
is VeranstalterDetailIntent.FilterChanged -> {
_state.value = _state.value.copy(statusFilter = intent.filter)
applyFilter()
}
is VeranstalterDetailIntent.DeleteVeranstaltung -> deleteVeranstaltung(intent.id)
}
}
private fun load(id: Long) {
_state.value = _state.value.copy(isLoading = true)
scope.launch {
// In einer echten App würden wir hier das Repo abfragen.
// Für den Prototyp nutzen wir vorerst die Logik aus dem Screen, aber im VM gekapselt.
val mockVeranstalter = VeranstalterDetailUiModel(
id = id,
name = "Reit- und Fahrverein Wels",
oepsNummer = "V-OOE-1234",
ansprechpartner = "Maria Huber",
email = "office@rfv-wels.at",
telefon = "+43 7242 12345",
adresse = "Reitweg 15\n4600 Wels",
loginStatus = LoginStatus.AKTIV,
mitgliedSeit = "15.1.2023",
)
val mockVeranstaltungen = listOf(
VeranstaltungListUiModel(
id = 1L,
name = "Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026",
datum = "25.-26. April 2026",
ort = "Reitanlage Stroblmair, Neumarkt/M., OO",
turnierAnzahl = 2,
nennungen = 87,
bewerbe = 26,
letzteAktivitaet = "22.03.2026 14:30",
status = VeranstaltungStatus.VORBEREITUNG,
),
VeranstaltungListUiModel(
id = 2L,
name = "AWÖ-Cup Stadl-Paura 2025",
datum = "15.-17. Mai 2025",
ort = "Bundesgestüt Piber, Stadl-Paura",
turnierAnzahl = 2,
nennungen = 142,
bewerbe = 33,
letzteAktivitaet = "17.05.2025 18:45",
status = VeranstaltungStatus.ABGESCHLOSSEN,
),
VeranstaltungListUiModel(
id = 3L,
name = "Linzer Pferdetage 2026",
datum = "12.-14. Juni 2026",
ort = "Reitsportzentrum Linz-Ebelsberg",
turnierAnzahl = 2,
nennungen = 23,
bewerbe = 30,
letzteAktivitaet = "20.03.2026 09:15",
status = VeranstaltungStatus.VORBEREITUNG,
),
)
_state.value = _state.value.copy(
isLoading = false,
veranstalter = mockVeranstalter,
veranstaltungen = mockVeranstaltungen,
filteredVeranstaltungen = mockVeranstaltungen
)
applyFilter()
}
}
private fun deleteVeranstaltung(id: Long) {
val newList = _state.value.veranstaltungen.filter { it.id != id }
_state.value = _state.value.copy(veranstaltungen = newList)
applyFilter()
}
private fun applyFilter() {
val current = _state.value
val filtered = current.veranstaltungen.filter { v ->
val matchesSearch = v.name.contains(current.suchtext, ignoreCase = true) ||
v.ort.contains(current.suchtext, ignoreCase = true)
val matchesStatus = when (current.statusFilter) {
VeranstaltungStatusFilter.ALLE -> true
VeranstaltungStatusFilter.VORBEREITUNG -> v.status == VeranstaltungStatus.VORBEREITUNG
VeranstaltungStatusFilter.LIVE -> v.status == VeranstaltungStatus.LIVE
VeranstaltungStatusFilter.ABGESCHLOSSEN -> v.status == VeranstaltungStatus.ABGESCHLOSSEN
}
matchesSearch && matchesStatus
}
_state.value = _state.value.copy(filteredVeranstaltungen = filtered)
}
}
@@ -43,8 +43,7 @@ import at.mocode.frontend.features.profile.presentation.ProfileScreen
import at.mocode.frontend.features.profile.presentation.ProfileViewModel import at.mocode.frontend.features.profile.presentation.ProfileViewModel
import at.mocode.frontend.features.reiter.presentation.ReiterScreen import at.mocode.frontend.features.reiter.presentation.ReiterScreen
import at.mocode.frontend.features.reiter.presentation.ReiterViewModel import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
import at.mocode.frontend.features.turnier.presentation.SeriesScreen import at.mocode.frontend.features.turnier.presentation.*
import at.mocode.frontend.features.turnier.presentation.TurnierDetailScreen
import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfigScreen import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfigScreen
import at.mocode.frontend.features.verein.presentation.VereinScreen import at.mocode.frontend.features.verein.presentation.VereinScreen
import at.mocode.frontend.features.verein.presentation.VereinViewModel import at.mocode.frontend.features.verein.presentation.VereinViewModel
@@ -52,12 +51,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.Store
import at.mocode.frontend.shell.desktop.data.Turnier import at.mocode.frontend.shell.desktop.data.Turnier
import at.mocode.frontend.shell.desktop.data.TurnierStore 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.VeranstalterAuswahl
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterDetail 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.management.VeranstalterVerwaltungScreen
import at.mocode.frontend.shell.desktop.screens.nennung.NennungsEingangScreen 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.VeranstaltungVerwaltung
import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.TurnierWizard import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.TurnierWizard
@@ -68,6 +65,7 @@ import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
// Primärfarbe der TopBar (kann später ins Theme ausgelagert werden) // Primärfarbe der TopBar (kann später ins Theme ausgelagert werden)
@@ -591,7 +589,7 @@ private fun DesktopContentArea(
is AppScreen.DeviceInitialization -> { is AppScreen.DeviceInitialization -> {
println("[Screen] Rendering DeviceInitialization") println("[Screen] Rendering DeviceInitialization")
val viewModel = koinViewModel<DeviceInitializationViewModel> { val viewModel = koinViewModel<DeviceInitializationViewModel> {
org.koin.core.parameter.parametersOf({ finalSettings: DeviceInitializationSettings -> parametersOf({ finalSettings: DeviceInitializationSettings ->
DeviceInitializationSettingsManager.saveSettings(finalSettings) DeviceInitializationSettingsManager.saveSettings(finalSettings)
// Vision_04: Sicherheitsschlüssel als Token setzen, damit Cloud-Suche funktioniert // Vision_04: Sicherheitsschlüssel als Token setzen, damit Cloud-Suche funktioniert
val authTokenManager = val authTokenManager =
@@ -839,10 +837,17 @@ private fun DesktopContentArea(
val veranstaltung = Store.eventsFor(parent.id).firstOrNull { it.id == evtId } val veranstaltung = Store.eventsFor(parent.id).firstOrNull { it.id == evtId }
val blCode = parent.oepsNummer.split("-").getOrNull(1) ?: "" val blCode = parent.oepsNummer.split("-").getOrNull(1) ?: ""
val bundesland = mapOepsToBundesland(blCode) val bundesland = mapOepsToBundesland(blCode)
val bewerbViewModel: BewerbViewModel = koinInject { parametersOf(currentScreen.turnierId) }
val nennungViewModel: TurnierNennungViewModel = koinInject { parametersOf(currentScreen.turnierId) }
val stammdatenViewModel: TurnierStammdatenViewModel = koinInject()
TurnierDetailScreen( TurnierDetailScreen(
veranstaltungId = evtId, veranstaltungId = evtId,
turnierId = currentScreen.turnierId, turnierId = currentScreen.turnierId,
onBack = onBack, bewerbViewModel = bewerbViewModel,
nennungViewModel = nennungViewModel,
stammdatenViewModel = stammdatenViewModel,
eventVon = veranstaltung?.datumVon, eventVon = veranstaltung?.datumVon,
eventBis = veranstaltung?.datumBis, eventBis = veranstaltung?.datumBis,
eventOrt = veranstaltung?.ort, eventOrt = veranstaltung?.ort,
@@ -904,14 +909,6 @@ private fun DesktopContentArea(
) )
} }
is AppScreen.Vereine -> {
println("[Screen] Rendering Vereine (VereinScreen)")
val vereinViewModel: VereinViewModel = koinViewModel()
VereinScreen(
viewModel = vereinViewModel
)
}
// --- Billing --- // --- Billing ---
is AppScreen.Billing -> { is AppScreen.Billing -> {
val billingViewModel: BillingViewModel = koinViewModel() val billingViewModel: BillingViewModel = koinViewModel()
@@ -1,22 +1,14 @@
package at.mocode.frontend.shell.desktop.screens.management package at.mocode.frontend.shell.desktop.screens.management
import androidx.compose.foundation.background import androidx.compose.runtime.Composable
import androidx.compose.foundation.clickable import at.mocode.frontend.features.veranstalter.presentation.VeranstalterAuswahlScreen
import androidx.compose.foundation.layout.* import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailScreen
import androidx.compose.foundation.lazy.LazyColumn import org.koin.compose.koinInject
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons /**
import androidx.compose.material.icons.automirrored.filled.ArrowBack * Delegates für die Veranstalter-Screens in der Desktop-Shell.
import androidx.compose.material.icons.filled.ChevronRight * Diese binden die Plug-and-Play Komponenten aus dem veranstalter-feature ein.
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.shell.desktop.data.Store
import at.mocode.frontend.shell.desktop.theme.DesktopTheme
@Composable @Composable
fun VeranstalterAuswahl( fun VeranstalterAuswahl(
@@ -24,55 +16,12 @@ fun VeranstalterAuswahl(
onWeiter: (Long) -> Unit, onWeiter: (Long) -> Unit,
onNeu: () -> Unit, onNeu: () -> Unit,
) { ) {
DesktopTheme { VeranstalterAuswahlScreen(
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { viewModel = koinInject(),
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { onZurueck = onBack,
Icon( onWeiter = onWeiter,
Icons.AutoMirrored.Filled.ArrowBack, onNeuerVeranstalter = onNeu
contentDescription = "Zurück", )
modifier = Modifier.clickable { onBack() })
Text("Veranstalter auswählen", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.weight(1f))
OutlinedButton(onClick = onNeu) { Text("+ Neuer Veranstalter") }
}
var selectedId by remember { mutableStateOf<Long?>(null) }
LazyColumn(Modifier.fillMaxSize().weight(1f)) {
items(Store.vereine) { v ->
val sel = selectedId == v.id
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable { selectedId = v.id },
colors = if (sel) CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
else CardDefaults.cardColors()
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(40.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text((v.kurzname ?: v.name).take(2).uppercase(), color = Color.White)
}
Spacer(Modifier.width(12.dp))
Column {
Text(v.name, style = MaterialTheme.typography.titleMedium)
Text("OEPS: ${v.oepsNummer} · ${v.ort ?: ""}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
Button(
onClick = { selectedId?.let(onWeiter) },
enabled = selectedId != null,
modifier = Modifier.fillMaxWidth()
) { Text("Weiter zum Veranstalter") }
}
}
} }
@Composable @Composable
@@ -82,127 +31,11 @@ fun VeranstalterDetail(
onZurVeranstaltung: (Long) -> Unit, onZurVeranstaltung: (Long) -> Unit,
onNeuVeranstaltung: () -> Unit, onNeuVeranstaltung: () -> Unit,
) { ) {
LaunchedEffect(veranstalterId) { println("[Screen] VeranstalterDetail geladen (VstID: $veranstalterId)") } VeranstalterDetailScreen(
DesktopTheme { veranstalterId = veranstalterId,
val verein = remember(veranstalterId) { Store.vereine.firstOrNull { it.id == veranstalterId } } viewModel = koinInject(),
onZurueck = onBack,
if (verein == null) { onVeranstaltungOeffnen = onZurVeranstaltung,
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onVeranstaltungNeu = onNeuVeranstaltung
Text("Veranstalter nicht gefunden") )
}
return@DesktopTheme
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
modifier = Modifier.clickable { onBack() })
Text(verein.name, style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.weight(1f))
Button(onClick = onNeuVeranstaltung) { Text("+ Neue Veranstaltung") }
}
var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text(
(verein.kurzname ?: verein.name).take(2).uppercase(),
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(verein.name, style = MaterialTheme.typography.titleMedium)
val line2 = listOfNotNull(
"OEPS: ${verein.oepsNummer}",
verein.ort,
verein.plz,
verein.strasse
).filter { it.isNotBlank() }.joinToString(" · ")
if (line2.isNotBlank()) Text(line2, color = Color(0xFF6B7280))
val line3 = listOfNotNull(verein.email, verein.telefon).filter { it.isNotBlank() }.joinToString(" · ")
if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280))
}
Button(onClick = { editOpen = true }) { Text("bearbeiten") }
}
}
if (editOpen) {
var name by remember { mutableStateOf(verein.name) }
var oeps by remember { mutableStateOf(verein.oepsNummer) }
var ort by remember { mutableStateOf(verein.ort ?: "") }
var plz by remember { mutableStateOf(verein.plz ?: "") }
var strasse by remember { mutableStateOf(verein.strasse ?: "") }
var email by remember { mutableStateOf(verein.email ?: "") }
var tel by remember { mutableStateOf(verein.telefon ?: "") }
var logo by remember { mutableStateOf(verein.logoUrl ?: "") }
AlertDialog(
onDismissRequest = { editOpen = false },
confirmButton = {
TextButton(onClick = {
verein.name = name
verein.oepsNummer = oeps
verein.ort = ort.ifBlank { null }
verein.plz = plz.ifBlank { null }
verein.strasse = strasse.ifBlank { null }
verein.email = email.ifBlank { null }
verein.telefon = tel.ifBlank { null }
verein.logoUrl = logo.ifBlank { null }
editOpen = false
}) { Text("Speichern") }
},
dismissButton = { TextButton(onClick = { editOpen = false }) { Text("Abbrechen") } },
title = { Text("Veranstalter bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(oeps, { oeps = it }, label = { Text("OEPS-Nummer") }, modifier = Modifier.weight(1f))
OutlinedTextField(logo, { logo = it }, label = { Text("Logo-URL") }, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(ort, { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
OutlinedTextField(plz, { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
}
OutlinedTextField(
strasse,
{ strasse = it },
label = { Text("Straße") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
OutlinedTextField(tel, { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
}
}
}
)
}
Text("Veranstaltungen", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
val events = remember(veranstalterId) { Store.eventsFor(veranstalterId) }
if (events.isEmpty()) {
Text("Keine Veranstaltungen angelegt", style = MaterialTheme.typography.bodyMedium, color = Color.Gray)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(events) { ev ->
ListItem(
headlineContent = { Text(ev.titel) },
supportingContent = { Text("${ev.datumVon} bis ${ev.datumBis ?: "?"} · ${ev.status}") },
trailingContent = { Icon(Icons.Default.ChevronRight, null) },
modifier = Modifier.clickable { onZurVeranstaltung(ev.id) }
)
}
}
}
}
}
} }
@@ -3,15 +3,15 @@ package at.mocode.frontend.shell.desktop.screens.preview
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import at.mocode.frontend.core.designsystem.preview.ComponentPreview import at.mocode.frontend.core.designsystem.preview.ComponentPreview
import at.mocode.frontend.features.turnier.domain.*
import at.mocode.frontend.features.turnier.presentation.*
import at.mocode.frontend.features.turnier.data.remote.dto.NennungEinreichenRequest import at.mocode.frontend.features.turnier.data.remote.dto.NennungEinreichenRequest
import at.mocode.zns.parser.ZnsBewerb import at.mocode.frontend.features.turnier.domain.*
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterAuswahlScreen
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailScreen
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterNeuScreen
import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile
import at.mocode.frontend.features.turnier.presentation.*
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.veranstalter.presentation.*
import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen
import at.mocode.zns.parser.ZnsBewerb
import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Compose Desktop Previews – alle wichtigen Screens auf einen Blick // Compose Desktop Previews – alle wichtigen Screens auf einen Blick
@@ -25,8 +25,20 @@ import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScree
@ComponentPreview @ComponentPreview
@Composable @Composable
fun PreviewVeranstalterAuswahlScreen() { fun PreviewVeranstalterAuswahlScreen() {
val mockRepo = object : VeranstalterRepository {
override suspend fun list(): Result<List<DomainVeranstalter>> = Result.success(emptyList())
override suspend fun getById(id: Long): Result<DomainVeranstalter> = Result.failure(NotImplementedError())
override suspend fun create(model: DomainVeranstalter): Result<DomainVeranstalter> =
Result.failure(NotImplementedError())
override suspend fun update(id: Long, model: DomainVeranstalter): Result<DomainVeranstalter> =
Result.failure(NotImplementedError())
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
}
MaterialTheme { MaterialTheme {
VeranstalterAuswahlScreen( VeranstalterAuswahlScreen(
viewModel = VeranstalterViewModel(mockRepo),
onZurueck = {}, onZurueck = {},
onWeiter = {}, onWeiter = {},
onNeuerVeranstalter = {}, onNeuerVeranstalter = {},
@@ -52,9 +64,21 @@ fun PreviewVeranstalterNeuScreen() {
@ComponentPreview @ComponentPreview
@Composable @Composable
fun PreviewVeranstalterDetailScreen() { fun PreviewVeranstalterDetailScreen() {
val mockRepo = object : VeranstalterRepository {
override suspend fun list(): Result<List<DomainVeranstalter>> = Result.success(emptyList())
override suspend fun getById(id: Long): Result<DomainVeranstalter> = Result.failure(NotImplementedError())
override suspend fun create(model: DomainVeranstalter): Result<DomainVeranstalter> =
Result.failure(NotImplementedError())
override suspend fun update(id: Long, model: DomainVeranstalter): Result<DomainVeranstalter> =
Result.failure(NotImplementedError())
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
}
MaterialTheme { MaterialTheme {
VeranstalterDetailScreen( VeranstalterDetailScreen(
veranstalterId = 1L, veranstalterId = 1L,
viewModel = VeranstalterDetailViewModel(mockRepo),
onZurueck = {}, onZurueck = {},
onVeranstaltungOeffnen = {}, onVeranstaltungOeffnen = {},
onVeranstaltungNeu = {}, onVeranstaltungNeu = {},
@@ -86,11 +110,69 @@ fun PreviewVeranstaltungUebersichtScreen() {
@ComponentPreview @ComponentPreview
@Composable @Composable
fun PreviewTurnierDetailScreen() { fun PreviewTurnierDetailScreen() {
val mockTurnierRepo = object : TurnierRepository {
override suspend fun list(): Result<List<Turnier>> = Result.success(emptyList())
override suspend fun getById(id: Long): Result<Turnier> = Result.success(Turnier(id, "Test Turnier"))
override suspend fun create(model: Turnier): Result<Turnier> = Result.success(model)
override suspend fun update(id: Long, model: Turnier): Result<Turnier> = Result.success(model)
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
}
val mockBewerbRepo = object : BewerbRepository {
override suspend fun list(turnierId: Long): Result<List<Bewerb>> = Result.success(emptyList())
override suspend fun getById(id: Long): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun create(model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun update(id: Long, model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> =
Result.failure(NotImplementedError())
override suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>> = Result.success(emptyList())
override suspend fun exportZnsBSatz(turnierId: Long): Result<String> = Result.success("")
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit)
}
val mockStartlistenRepo = object : StartlistenRepository {
override suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
override suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
}
val mockErgebnisRepo = object : ErgebnisRepository {
override suspend fun getForBewerb(bewerbId: String): Result<List<Ergebnis>> = Result.success(emptyList())
override suspend fun save(ergebnis: Ergebnis): Result<Ergebnis> = Result.success(ergebnis)
override suspend fun calculatePlatzierung(bewerbId: String): Result<List<Ergebnis>> = Result.success(emptyList())
override suspend fun exportPdf(bewerbId: String): Result<ByteArray> = Result.success(ByteArray(0))
}
val bewerbVm = BewerbViewModel(mockBewerbRepo, mockStartlistenRepo, mockErgebnisRepo, null, 1L)
val mockNennungRepo = object : NennungRepository {
override suspend fun list(turnierId: Long): Result<List<Nennung>> = Result.success(emptyList())
override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = Result.success(emptyList())
override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> =
Result.failure(NotImplementedError())
override suspend fun updateStatus(id: String, status: String): Result<Nennung> =
Result.failure(NotImplementedError())
override suspend fun delete(id: String): Result<Unit> = Result.success(Unit)
}
val mockMasterdataRepo = object : MasterdataRepository {
override suspend fun searchReiter(query: String): Result<List<Reiter>> = Result.success(emptyList())
override suspend fun searchPferde(query: String): Result<List<Pferd>> = Result.success(emptyList())
override suspend fun getReiter(id: String): Result<Reiter> = Result.failure(NotImplementedError())
override suspend fun saveReiter(reiter: Reiter): Result<Reiter> = Result.success(reiter)
override suspend fun getPferd(id: String): Result<Pferd> = Result.failure(NotImplementedError())
override suspend fun savePferd(pferd: Pferd): Result<Pferd> = Result.success(pferd)
override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = Result.success(emptyList())
override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList())
override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError())
}
val nennungVm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
val stammdatenVm = TurnierStammdatenViewModel(mockTurnierRepo)
MaterialTheme { MaterialTheme {
TurnierDetailScreen( TurnierDetailScreen(
veranstaltungId = 1L, veranstaltungId = 1L,
turnierId = 1L, turnierId = 1L,
onBack = {}, bewerbViewModel = bewerbVm,
nennungViewModel = nennungVm,
stammdatenViewModel = stammdatenVm,
) )
} }
} }
@@ -100,8 +182,16 @@ fun PreviewTurnierDetailScreen() {
@ComponentPreview @ComponentPreview
@Composable @Composable
fun PreviewTurnierStammdatenTab() { fun PreviewTurnierStammdatenTab() {
val mockTurnierRepo = object : TurnierRepository {
override suspend fun list(): Result<List<Turnier>> = Result.success(emptyList())
override suspend fun getById(id: Long): Result<Turnier> = Result.success(Turnier(id, "Test Turnier"))
override suspend fun create(model: Turnier): Result<Turnier> = Result.success(model)
override suspend fun update(id: Long, model: Turnier): Result<Turnier> = Result.success(model)
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
}
val vm = TurnierStammdatenViewModel(mockTurnierRepo)
MaterialTheme { MaterialTheme {
StammdatenTabContent(turnierId = 1L) StammdatenTabContent(turnierId = 1L, viewModel = vm)
} }
} }
@@ -111,8 +201,12 @@ fun PreviewTurnierOrganisationTab() {
val mockNennungRepo = object : NennungRepository { val mockNennungRepo = object : NennungRepository {
override suspend fun list(turnierId: Long): Result<List<Nennung>> = Result.success(emptyList()) override suspend fun list(turnierId: Long): Result<List<Nennung>> = Result.success(emptyList())
override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = Result.success(emptyList()) override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = Result.success(emptyList())
override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> = Result.failure(NotImplementedError()) override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> =
override suspend fun updateStatus(id: String, status: String): Result<Nennung> = Result.failure(NotImplementedError()) Result.failure(NotImplementedError())
override suspend fun updateStatus(id: String, status: String): Result<Nennung> =
Result.failure(NotImplementedError())
override suspend fun delete(id: String): Result<Unit> = Result.success(Unit) override suspend fun delete(id: String): Result<Unit> = Result.success(Unit)
} }
val mockMasterdataRepo = object : MasterdataRepository { val mockMasterdataRepo = object : MasterdataRepository {
@@ -141,9 +235,13 @@ fun PreviewTurnierBewerbeTab() {
override suspend fun create(model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError()) override suspend fun create(model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun update(id: Long, model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError()) override suspend fun update(id: Long, model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit) override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> = Result.failure(NotImplementedError()) override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> =
Result.failure(NotImplementedError())
override suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>> = Result.success(emptyList()) override suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>> = Result.success(emptyList())
override suspend fun exportZnsBSatz(turnierId: Long): Result<String> = Result.success("BBEWERBE\r\n B0100Bewerb 1 A01 20260411001\r\n") override suspend fun exportZnsBSatz(turnierId: Long): Result<String> =
Result.success("BBEWERBE\r\n B0100Bewerb 1 A01 20260411001\r\n")
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit) override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit)
} }
val mockStartlistenRepo = object : StartlistenRepository { val mockStartlistenRepo = object : StartlistenRepository {
@@ -190,8 +288,12 @@ fun PreviewTurnierNennungenTab() {
val mockNennungRepo = object : NennungRepository { val mockNennungRepo = object : NennungRepository {
override suspend fun list(turnierId: Long): Result<List<Nennung>> = Result.success(emptyList()) override suspend fun list(turnierId: Long): Result<List<Nennung>> = Result.success(emptyList())
override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = Result.success(emptyList()) override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = Result.success(emptyList())
override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> = Result.failure(NotImplementedError()) override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> =
override suspend fun updateStatus(id: String, status: String): Result<Nennung> = Result.failure(NotImplementedError()) Result.failure(NotImplementedError())
override suspend fun updateStatus(id: String, status: String): Result<Nennung> =
Result.failure(NotImplementedError())
override suspend fun delete(id: String): Result<Unit> = Result.success(Unit) override suspend fun delete(id: String): Result<Unit> = Result.success(Unit)
} }
val mockMasterdataRepo = object : MasterdataRepository { val mockMasterdataRepo = object : MasterdataRepository {
+1 -1
View File
@@ -73,7 +73,7 @@ dev.port.offset=0
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische # Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische
# Module zu testen. Default=false spart massiv Zeit beim Desktop-Build. # 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) # Dokka Gradle plugin V2 mode (with helpers for V1 compatibility)
# See https://kotl.in/dokka-gradle-migration # See https://kotl.in/dokka-gradle-migration