From 691861a7069ff1625dd7ad407d6f18f7cdf1995a Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Sun, 19 Apr 2026 17:39:28 +0200 Subject: [PATCH] chore: entferne veraltete `turnier-feature` Artefakte und ViewModels nach Migration auf Module Structure Blueprint --- ...-04-19_Frontend_Final_Blueprint_Cleanup.md | 33 + .../features/ping/di/PingFeatureModule.kt | 10 +- .../ping/feature/data/PingApiKoinClient.kt | 41 - .../feature/data/PingEventRepositoryImpl.kt | 43 - .../ping/feature/domain/PingSyncService.kt | 26 - .../feature/presentation/PingActionGroup.kt | 102 -- .../ping/feature/presentation/PingScreen.kt | 237 ---- .../feature/presentation/PingViewModel.kt | 156 --- .../feature/presentation/TerminalConsole.kt | 66 -- .../feature/data/PingApiKoinClientTest.kt | 173 --- .../integration/PingSyncIntegrationTest.kt | 70 -- .../feature/presentation/PingViewModelTest.kt | 312 ----- .../at/mocode/ping/feature/test/Fakes.kt | 186 --- .../feature/presentation/PingScreenPreview.kt | 105 -- .../feature/data/mapper/TurnierMapper.kt | 52 - .../turnier/feature/data/remote/BewerbApi.kt | 87 -- .../data/remote/DefaultAbteilungRepository.kt | 71 -- .../data/remote/DefaultBewerbRepository.kt | 127 -- .../data/remote/DefaultErgebnisRepository.kt | 40 - .../remote/DefaultMasterdataRepository.kt | 166 --- .../data/remote/DefaultNennungRepository.kt | 88 -- .../data/remote/DefaultSeriesRepository.kt | 41 - .../remote/DefaultStartlistenRepository.kt | 34 - .../data/remote/DefaultTurnierRepository.kt | 71 -- .../feature/data/remote/dto/NennungDto.kt | 49 - .../feature/data/remote/dto/TurnierDto.kt | 42 - .../feature/domain/AbteilungRepository.kt | 15 - .../feature/domain/BewerbRepository.kt | 50 - .../feature/domain/ErgebnisRepository.kt | 27 - .../feature/domain/MasterdataRepository.kt | 50 - .../feature/domain/NennungRepository.kt | 25 - .../feature/domain/SeriesRepository.kt | 40 - .../feature/domain/StartlistenRepository.kt | 8 - .../feature/domain/TurnierRepository.kt | 14 - .../feature/domain/model/StartlistenZeile.kt | 13 - .../turnier/di/TurnierFeatureModule.kt | 20 +- .../presentation/AbteilungViewModel.kt | 132 --- .../feature/presentation/AktorScreens.kt | 73 -- .../presentation/BewerbAnlegenViewModel.kt | 88 -- .../feature/presentation/BewerbViewModel.kt | 392 ------- .../presentation/CreateBewerbWizardScreen.kt | 316 ----- .../presentation/MasterdataEditDialogs.kt | 212 ---- .../feature/presentation/SeriesScreen.kt | 120 -- .../feature/presentation/SeriesViewModel.kt | 60 - .../presentation/TurnierAbrechnungTab.kt | 292 ----- .../feature/presentation/TurnierArtikelTab.kt | 263 ----- .../feature/presentation/TurnierBewerbeTab.kt | 1030 ----------------- .../presentation/TurnierDetailScreen.kt | 137 --- .../presentation/TurnierErgebnislistenTab.kt | 269 ----- .../presentation/TurnierNennungViewModel.kt | 163 --- .../presentation/TurnierNennungenTab.kt | 289 ----- .../feature/presentation/TurnierNeuScreen.kt | 65 -- .../presentation/TurnierOnlineNennungenTab.kt | 108 -- .../presentation/TurnierOrganisationTab.kt | 523 --------- .../presentation/TurnierStammdatenTab.kt | 631 ---------- .../presentation/TurnierStartlistenTab.kt | 241 ---- .../feature/presentation/TurnierViewModel.kt | 106 -- .../feature/presentation/TurnierWizardV2.kt | 152 --- .../presentation/TurnierZeitplanTab.kt | 412 ------- .../screens/layout/DesktopMainLayout.kt | 8 +- .../desktop/screens/preview/ScreenPreviews.kt | 8 +- 61 files changed, 56 insertions(+), 8724 deletions(-) create mode 100644 docs/99_Journal/2026-04-19_Frontend_Final_Blueprint_Cleanup.md delete mode 100644 frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingApiKoinClient.kt delete mode 100644 frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingEventRepositoryImpl.kt delete mode 100644 frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/domain/PingSyncService.kt delete mode 100644 frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingActionGroup.kt delete mode 100644 frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingScreen.kt delete mode 100644 frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingViewModel.kt delete mode 100644 frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/TerminalConsole.kt delete mode 100644 frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/data/PingApiKoinClientTest.kt delete mode 100644 frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/integration/PingSyncIntegrationTest.kt delete mode 100644 frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/presentation/PingViewModelTest.kt delete mode 100644 frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/test/Fakes.kt delete mode 100644 frontend/features/ping-feature/src/jvmMain/kotlin/at/mocode/ping/feature/presentation/PingScreenPreview.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/BewerbApi.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultAbteilungRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultErgebnisRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultMasterdataRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultNennungRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultSeriesRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultStartlistenRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultTurnierRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/NennungDto.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/AbteilungRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/ErgebnisRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/MasterdataRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/NennungRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/SeriesRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/StartlistenRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/TurnierRepository.kt delete mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/model/StartlistenZeile.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/AbteilungViewModel.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/AktorScreens.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/BewerbAnlegenViewModel.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/CreateBewerbWizardScreen.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/MasterdataEditDialogs.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesScreen.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesViewModel.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierAbrechnungTab.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierArtikelTab.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNennungViewModel.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNennungenTab.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNeuScreen.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierOnlineNennungenTab.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierOrganisationTab.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStartlistenTab.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierViewModel.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt delete mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt diff --git a/docs/99_Journal/2026-04-19_Frontend_Final_Blueprint_Cleanup.md b/docs/99_Journal/2026-04-19_Frontend_Final_Blueprint_Cleanup.md new file mode 100644 index 00000000..831716eb --- /dev/null +++ b/docs/99_Journal/2026-04-19_Frontend_Final_Blueprint_Cleanup.md @@ -0,0 +1,33 @@ +# Journal: Finalisierung der Frontend-Blueprint-Migration + +**Datum:** 19. April 2026 +**Status:** Abgeschlossen +**Agent:** 🏗️ [Lead Architect] | 🧹 [Curator] + +## 🎯 Zielsetzung +Nach der großflächigen Migration der Core- und Feature-Module wurden im letzten Schritt verbleibende strukturelle Inkonsistenzen in den Modulen `ping-feature` und `turnier-feature` bereinigt. Ziel war die vollständige Einhaltung des **Module Structure Blueprint** (Klasse B). + +## 🛠️ Durchgeführte Änderungen + +### 1. Paket-Synchronisierung (`ping-feature`) +* Das veraltete Paket `at.mocode.ping.feature` wurde konsistent in `at.mocode.frontend.features.ping` umbenannt. +* Dies betraf die Source-Sets `commonMain`, `jvmMain` und `commonTest`. +* Die physische Verzeichnisstruktur wurde von `at/mocode/ping/feature/` nach `at/mocode/frontend/features/ping/` verschoben. + +### 2. Paket-Synchronisierung (`turnier-feature`) +* Das veraltete Paket `at.mocode.turnier.feature` wurde konsistent in `at.mocode.frontend.features.turnier` umbenannt. +* Betroffen waren alle Ebenen (`commonMain`, `jvmMain`, `wasmJsMain`) inklusive Unterpakete für `data`, `domain` und `presentation`. +* Die physische Verzeichnisstruktur wurde analog zum Standard angepasst. + +### 3. Shell-Integration +* Die Importe in `frontend/shells/meldestelle-desktop` wurden an die neuen Paketnamen angepasst, um die Lauffähigkeit der Desktop-App sicherzustellen. +* Die `meldestelle-web` Shell wurde ebenfalls verifiziert. + +## ✅ Verifizierung +* `./gradlew :frontend:features:ping-feature:assemble`: **ERFOLGREICH** +* `./gradlew :frontend:features:turnier-feature:assemble`: **ERFOLGREICH** +* `./gradlew :frontend:shells:meldestelle-desktop:assemble`: **ERFOLGREICH** +* `./gradlew :frontend:shells:meldestelle-web:assemble`: **ERFOLGREICH** + +## 🧹 Fazit +Mit diesem Schritt ist die strukturelle Bereinigung des Frontends abgeschlossen. Alle Module (Core, Features, Shells) folgen nun einem einheitlichen Namens- und Struktur-Schema. Die "Consistency Rule" des Blueprints ist somit im gesamten Frontend-Projekt erfüllt. diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/frontend/features/ping/di/PingFeatureModule.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/frontend/features/ping/di/PingFeatureModule.kt index b8c7bd5f..332e4ec1 100644 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/frontend/features/ping/di/PingFeatureModule.kt +++ b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/frontend/features/ping/di/PingFeatureModule.kt @@ -2,11 +2,11 @@ package at.mocode.frontend.features.ping.di import at.mocode.frontend.core.localdb.AppDatabase import at.mocode.ping.api.PingApi -import at.mocode.ping.feature.data.PingApiKoinClient -import at.mocode.ping.feature.data.PingEventRepositoryImpl -import at.mocode.ping.feature.domain.PingSyncService -import at.mocode.ping.feature.domain.PingSyncServiceImpl -import at.mocode.ping.feature.presentation.PingViewModel +import at.mocode.frontend.features.ping.data.PingApiKoinClient +import at.mocode.frontend.features.ping.data.PingEventRepositoryImpl +import at.mocode.frontend.features.ping.domain.PingSyncService +import at.mocode.frontend.features.ping.domain.PingSyncServiceImpl +import at.mocode.frontend.features.ping.presentation.PingViewModel import org.koin.core.qualifier.named import org.koin.dsl.module diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingApiKoinClient.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingApiKoinClient.kt deleted file mode 100644 index 84eeb9bc..00000000 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingApiKoinClient.kt +++ /dev/null @@ -1,41 +0,0 @@ -package at.mocode.ping.feature.data - -import at.mocode.ping.api.* -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* - -/** - * PingApi-Implementierung, die einen bereitgestellten HttpClient verwendet (z. B. den per Dependency Injection - * bereitgestellten "apiClient"). - */ -class PingApiKoinClient(private val client: HttpClient) : PingApi { - - override suspend fun simplePing(): PingResponse { - return client.get("/api/ping/simple").body() - } - - override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse { - return client.get("/api/ping/enhanced") { - url.parameters.append("simulate", simulate.toString()) - }.body() - } - - override suspend fun healthCheck(): HealthResponse { - return client.get("/api/ping/health").body() - } - - override suspend fun publicPing(): PingResponse { - return client.get("/api/ping/public").body() - } - - override suspend fun securePing(): PingResponse { - return client.get("/api/ping/secure").body() - } - - override suspend fun syncPings(since: Long): List { - return client.get("/api/ping/sync") { - url.parameters.append("since", since.toString()) - }.body() - } -} diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingEventRepositoryImpl.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingEventRepositoryImpl.kt deleted file mode 100644 index 637ebf2f..00000000 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingEventRepositoryImpl.kt +++ /dev/null @@ -1,43 +0,0 @@ -package at.mocode.ping.feature.data - -import at.mocode.frontend.core.localdb.AppDatabase -import at.mocode.frontend.core.sync.SyncableRepository -import at.mocode.ping.api.PingEvent -import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull - -/** - ** ARCH-BLUEPRINT: Dieses Repository implementiert das generische Syncable Repository - ** für eine bestimmte Entität und überbrückt so die Lücke zwischen dem Sync-Core und der - ** lokalen Datenbank. - */ -class PingEventRepositoryImpl( - private val db: AppDatabase -) : SyncableRepository { - - // Der `since`-Parameter für unsere Synchronisierung ist der Zeitstempel des letzten Ereignisses. - // Das Backend erwartet einen Long (Timestamp), keinen String (UUID). - override suspend fun getLatestSince(): String? { - println("PingEventRepositoryImpl: getLatestSince called - fetching latest timestamp") - // Wir holen den letzten Timestamp aus der DB. - val lastModified = db.appDatabaseQueries.selectLatestPingEventTimestamp().awaitAsOneOrNull() - - // Wir geben ihn als String zurück, da das Interface String? erwartet. - // Der SyncManager wird ihn als Parameter "since" an den Request hängen. - // Das Backend erwartet "since" als Long, aber HTTP Parameter sind Strings. - // Spring Boot konvertiert "123456789" automatisch in Long 123456789. - return lastModified?.toString() - } - - override suspend fun upsert(items: List) { - // Führen Sie Massenoperationen immer innerhalb einer Transaktion durch. - db.transaction { - items.forEach { event -> - db.appDatabaseQueries.upsertPingEvent( - id = event.id, - message = event.message, - last_modified = event.lastModified - ) - } - } - } -} diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/domain/PingSyncService.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/domain/PingSyncService.kt deleted file mode 100644 index b949d434..00000000 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/domain/PingSyncService.kt +++ /dev/null @@ -1,26 +0,0 @@ -package at.mocode.ping.feature.domain - -import at.mocode.frontend.core.sync.SyncManager -import at.mocode.frontend.core.sync.SyncableRepository -import at.mocode.ping.api.PingEvent - -/** - * Interface für den Ping-Sync-Dienst zur einfacheren Prüfung und Entkopplung. - */ -interface PingSyncService { - suspend fun syncPings() -} - -/** - * Implementierung des PingSyncService unter Verwendung des generischen SyncManager. - */ -class PingSyncServiceImpl( - private val syncManager: SyncManager, - private val repository: SyncableRepository -) : PingSyncService { - - override suspend fun syncPings() { - // Corrected endpoint: /api/ping/sync (singular) - syncManager.performSync(repository, "/api/ping/sync") - } -} diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingActionGroup.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingActionGroup.kt deleted file mode 100644 index fbb68523..00000000 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingActionGroup.kt +++ /dev/null @@ -1,102 +0,0 @@ -package at.mocode.ping.feature.presentation - -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.HealthAndSafety -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.NetworkCheck -import androidx.compose.material.icons.filled.Sync -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import at.mocode.frontend.core.designsystem.theme.Dimens - -/** - * Eine modulare Gruppe von Test-Buttons für die Konnektivitäts-Diagnose. - * Plug-and-Play fähig für Ping-Screen oder Sidebar. - */ -@Composable -fun PingActionGroup( - viewModel: PingViewModel, - modifier: Modifier = Modifier -) { - val uiState = viewModel.uiState - - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS) - ) { - Text( - text = "DIAGNOSE-TESTS", - style = MaterialTheme.typography.labelSmall, - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, - modifier = Modifier.padding(bottom = Dimens.SpacingXS) - ) - - // Grid-ähnliches Layout für die Buttons - Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) { - PingTestButton( - text = "Simple Ping", - icon = Icons.Default.NetworkCheck, - onClick = { viewModel.performSimplePing() }, - isLoading = uiState.isLoading, - modifier = Modifier.weight(1f) - ) - PingTestButton( - text = "Secure Ping", - icon = Icons.Default.Lock, - onClick = { viewModel.performSecurePing() }, - isLoading = uiState.isLoading, - modifier = Modifier.weight(1f) - ) - } - - Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) { - PingTestButton( - text = "Health Check", - icon = Icons.Default.HealthAndSafety, - onClick = { viewModel.performHealthCheck() }, - isLoading = uiState.isLoading, - modifier = Modifier.weight(1f) - ) - PingTestButton( - text = "Delta Sync", - icon = Icons.Default.Sync, - onClick = { viewModel.triggerSync() }, - isLoading = uiState.isSyncing, - modifier = Modifier.weight(1f) - ) - } - - // Zusätzlicher Button für Enhanced Ping (Circuit Breaker Test) - OutlinedButton( - onClick = { viewModel.performEnhancedPing() }, - modifier = Modifier.fillMaxWidth(), - enabled = !uiState.isLoading - ) { - Text("Enhanced Ping (Simulation)", fontSize = 12.sp) - } - } -} - -@Composable -private fun PingTestButton( - text: String, - icon: androidx.compose.ui.graphics.vector.ImageVector, - onClick: () -> Unit, - isLoading: Boolean, - modifier: Modifier = Modifier -) { - Button( - onClick = onClick, - modifier = modifier.height(48.dp), - enabled = !isLoading, - contentPadding = PaddingValues(horizontal = Dimens.SpacingS) - ) { - Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(Dimens.SpacingXS)) - Text(text, fontSize = 12.sp, maxLines = 1) - } -} diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingScreen.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingScreen.kt deleted file mode 100644 index b17bb407..00000000 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingScreen.kt +++ /dev/null @@ -1,237 +0,0 @@ -package at.mocode.ping.feature.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.material3.* -import androidx.compose.runtime.Composable -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.auth.presentation.AuthStatusCard -import at.mocode.frontend.core.auth.presentation.LoginViewModel -import at.mocode.frontend.core.designsystem.components.MsCard -import at.mocode.frontend.core.designsystem.theme.Dimens -import org.koin.compose.koinInject - -@Composable -fun PingScreen( - viewModel: PingViewModel, - onBack: () -> Unit = {}, - onNavigateToLogin: () -> Unit = {} -) { - val uiState = viewModel.uiState - val authViewModel: LoginViewModel = koinInject() - - // Wir nutzen jetzt das globale Theme (Hintergrund kommt vom Theme) - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .padding(Dimens.SpacingS) // Globales Spacing - ) { - // 1. Header - PingHeader( - onBack = onBack, - isSyncing = uiState.isSyncing, - isLoading = uiState.isLoading - ) - - Spacer(Modifier.height(Dimens.SpacingS)) - - // 2. Auth Status Area (Plug-and-Play) - AuthStatusCard( - viewModel = authViewModel, - onLoginClick = onNavigateToLogin - ) - - Spacer(Modifier.height(Dimens.SpacingS)) - - // 3. Main Dashboard Area (Split View) - Row(modifier = Modifier.weight(1f)) { - // Left Panel: Controls & Status Grid (60%) - Column( - modifier = Modifier - .weight(0.6f) - .fillMaxHeight() - .padding(end = Dimens.SpacingS) - ) { - PingActionGroup(viewModel) - Spacer(Modifier.height(Dimens.SpacingS)) - StatusGrid(uiState) - } - - // Right Panel: Terminal Log (40%) - TerminalConsole( - logs = uiState.logs, - onClear = { viewModel.clearLogs() }, - modifier = Modifier - .weight(0.4f) - .fillMaxHeight() - ) - } - - Spacer(Modifier.height(Dimens.SpacingXS)) - - // 4. Footer - PingStatusBar(uiState.lastSyncResult) - } -} - -@Composable -private fun PingHeader( - onBack: () -> Unit, - isSyncing: Boolean, - isLoading: Boolean -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().height(40.dp) - ) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onBackground) - } - Text( - "KONNEKTIVITÄTS-DIAGNOSE // DASHBOARD", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.weight(1f).padding(start = Dimens.SpacingS) - ) - - if (isLoading) { - StatusBadge("BUSY", Color(0xFFFFA000)) // Amber - Spacer(Modifier.width(Dimens.SpacingS)) - } - - if (isSyncing) { - StatusBadge("SYNCING", MaterialTheme.colorScheme.primary) - Spacer(Modifier.width(Dimens.SpacingS)) - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary - ) - } else { - StatusBadge("IDLE", Color(0xFF388E3C)) // Green - } - } -} - -@Composable -private fun StatusBadge(text: String, color: Color) { - Surface( - color = color.copy(alpha = 0.1f), - contentColor = color, - shape = MaterialTheme.shapes.small, - border = androidx.compose.foundation.BorderStroke(1.dp, color) - ) { - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) - ) - } -} - -@Composable -private fun StatusGrid(uiState: PingUiState) { - Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) { - // Row 1 - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) { - MsCard(modifier = Modifier.weight(1f)) { - StatusHeader("SIMPLE / SECURE PING") - if (uiState.simplePingResponse != null) { - KeyValueRow("Status", uiState.simplePingResponse.status) - KeyValueRow("Service", uiState.simplePingResponse.service) - KeyValueRow("Time", uiState.simplePingResponse.timestamp) - } else { - EmptyStateText() - } - } - - MsCard(modifier = Modifier.weight(1f)) { - StatusHeader("HEALTH CHECK") - if (uiState.healthResponse != null) { - KeyValueRow("Status", uiState.healthResponse.status) - KeyValueRow("Healthy", uiState.healthResponse.healthy.toString()) - KeyValueRow("Service", uiState.healthResponse.service) - } else { - EmptyStateText() - } - } - } - - // Row 2 - MsCard(modifier = Modifier.fillMaxWidth()) { - StatusHeader("ENHANCED PING (RESILIENCE)") - if (uiState.enhancedPingResponse != null) { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.weight(1f)) { - KeyValueRow("Status", uiState.enhancedPingResponse.status) - KeyValueRow("Timestamp", uiState.enhancedPingResponse.timestamp) - } - Column(modifier = Modifier.weight(1f)) { - KeyValueRow("Circuit Breaker", uiState.enhancedPingResponse.circuitBreakerState) - KeyValueRow("Latency", "${uiState.enhancedPingResponse.responseTime}ms") - } - } - } else { - EmptyStateText() - } - } - } -} - -@Composable -private fun StatusHeader(title: String) { - Text( - text = title, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = Dimens.SpacingXS) - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) - Spacer(Modifier.height(Dimens.SpacingXS)) -} - -@Composable -private fun EmptyStateText() { - Text("No Data", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) -} - -@Composable -private fun KeyValueRow(key: String, value: String) { - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { - Text( - text = "$key:", - modifier = Modifier.width(100.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = value, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface - ) - } -} - -@Composable -private fun PingStatusBar(lastSync: String?) { - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = lastSync ?: "Ready", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = Dimens.SpacingS, vertical = 2.dp) - ) - } -} diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingViewModel.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingViewModel.kt deleted file mode 100644 index fb4beada..00000000 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingViewModel.kt +++ /dev/null @@ -1,156 +0,0 @@ -package at.mocode.ping.feature.presentation - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import at.mocode.ping.api.EnhancedPingResponse -import at.mocode.ping.api.HealthResponse -import at.mocode.ping.api.PingApi -import at.mocode.ping.api.PingResponse -import at.mocode.ping.feature.domain.PingSyncService -import kotlinx.coroutines.launch -import kotlin.time.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime - -data class LogEntry( - val timestamp: String, - val source: String, - val message: String, - val isError: Boolean = false -) - -data class PingUiState( - val isLoading: Boolean = false, - val simplePingResponse: PingResponse? = null, - val enhancedPingResponse: EnhancedPingResponse? = null, - val healthResponse: HealthResponse? = null, - val errorMessage: String? = null, - val isSyncing: Boolean = false, - val lastSyncResult: String? = null, - val logs: List = emptyList() -) - -open class PingViewModel( - private val apiClient: PingApi, - private val syncService: PingSyncService -) : ViewModel() { - - var uiState by mutableStateOf(PingUiState()) - internal set - - private fun addLog(source: String, message: String, isError: Boolean = false) { - val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) - val timeString = "${now.hour.toString().padStart(2, '0')}:${now.minute.toString().padStart(2, '0')}:${ - now.second.toString().padStart(2, '0') - }" - val entry = LogEntry(timeString, source, message, isError) - uiState = uiState.copy(logs = listOf(entry) + uiState.logs) // Prepend for newest first - } - - fun performSimplePing() { - viewModelScope.launch { - uiState = uiState.copy(isLoading = true, errorMessage = null) - addLog("SimplePing", "Sending request...") - try { - val response = apiClient.simplePing() - uiState = uiState.copy( - isLoading = false, - simplePingResponse = response - ) - addLog("SimplePing", "Success: ${response.status} from ${response.service}") - } catch (e: Exception) { - val msg = "Simple ping failed: ${e.message}" - uiState = uiState.copy(isLoading = false, errorMessage = msg) - addLog("SimplePing", "Failed: ${e.message}", isError = true) - } - } - } - - fun performEnhancedPing(simulate: Boolean = false) { - viewModelScope.launch { - uiState = uiState.copy(isLoading = true, errorMessage = null) - addLog("EnhancedPing", "Sending request (simulate=$simulate)...") - try { - val response = apiClient.enhancedPing(simulate) - uiState = uiState.copy( - isLoading = false, - enhancedPingResponse = response - ) - addLog("EnhancedPing", "Success: CB=${response.circuitBreakerState}, Time=${response.responseTime}ms") - } catch (e: Exception) { - val msg = "Enhanced ping failed: ${e.message}" - uiState = uiState.copy(isLoading = false, errorMessage = msg) - addLog("EnhancedPing", "Failed: ${e.message}", isError = true) - } - } - } - - fun performHealthCheck() { - viewModelScope.launch { - uiState = uiState.copy(isLoading = true, errorMessage = null) - addLog("HealthCheck", "Checking system health...") - try { - val response = apiClient.healthCheck() - uiState = uiState.copy( - isLoading = false, - healthResponse = response - ) - addLog("HealthCheck", "Status: ${response.status}, Healthy: ${response.healthy}") - } catch (e: Exception) { - val msg = "Health check failed: ${e.message}" - uiState = uiState.copy(isLoading = false, errorMessage = msg) - addLog("HealthCheck", "Failed: ${e.message}", isError = true) - } - } - } - - fun performSecurePing() { - viewModelScope.launch { - uiState = uiState.copy(isLoading = true, errorMessage = null) - addLog("SecurePing", "Sending authenticated request...") - try { - val response = apiClient.securePing() - uiState = uiState.copy( - isLoading = false, - simplePingResponse = response - ) - addLog("SecurePing", "Success: Authorized access granted.") - } catch (e: Exception) { - val msg = "Secure ping failed: ${e.message}" - uiState = uiState.copy(isLoading = false, errorMessage = msg) - addLog("SecurePing", "Access Denied/Error: ${e.message}", isError = true) - } - } - } - - fun triggerSync() { - viewModelScope.launch { - uiState = uiState.copy(isSyncing = true, errorMessage = null) - addLog("Sync", "Starting delta sync...") - try { - syncService.syncPings() - val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) - uiState = uiState.copy( - isSyncing = false, - lastSyncResult = "Sync successful at $now" - ) - addLog("Sync", "Sync completed successfully.") - } catch (e: Exception) { - val msg = "Sync failed: ${e.message}" - uiState = uiState.copy(isSyncing = false, errorMessage = msg) - addLog("Sync", "Sync failed: ${e.message}", isError = true) - } - } - } - - fun clearLogs() { - uiState = uiState.copy(logs = emptyList()) - } - - fun clearError() { - uiState = uiState.copy(errorMessage = null) - } -} diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/TerminalConsole.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/TerminalConsole.kt deleted file mode 100644 index a83041df..00000000 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/TerminalConsole.kt +++ /dev/null @@ -1,66 +0,0 @@ -package at.mocode.ping.feature.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.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -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.theme.Dimens - -/** - * Eine universelle Terminal-Konsole zur Anzeige von Log-Einträgen. - * Plug-and-Play ist fähig für verschiedene Features (Ping, Sync, Auth-Logs). - */ -@Composable -fun TerminalConsole( - logs: List, - modifier: Modifier = Modifier, - onClear: () -> Unit = {} -) { - Column(modifier = modifier) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = Dimens.SpacingXS), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text("EVENT LOG", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) - TextButton( - onClick = onClear, - contentPadding = PaddingValues(0.dp), - modifier = Modifier.height(24.dp) - ) { - Text("CLEAR", style = MaterialTheme.typography.labelSmall) - } - } - - LazyColumn( - modifier = Modifier - .fillMaxSize() - .background(Color(0xFF1E1E1E)) // Terminallook (Dunkel) - .padding(Dimens.SpacingXS) - ) { - items(logs) { log -> - val color = if (log.isError) Color(0xFFFF5555) else Color(0xFF55FF55) - Text( - text = "[${log.timestamp}] [${log.source}] ${log.message}", - color = color, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - lineHeight = 14.sp - ) - } - } - } -} diff --git a/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/data/PingApiKoinClientTest.kt b/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/data/PingApiKoinClientTest.kt deleted file mode 100644 index a2201869..00000000 --- a/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/data/PingApiKoinClientTest.kt +++ /dev/null @@ -1,173 +0,0 @@ -package at.mocode.ping.feature.data - -import at.mocode.ping.api.EnhancedPingResponse -import at.mocode.ping.api.HealthResponse -import at.mocode.ping.api.PingResponse -import io.ktor.client.* -import io.ktor.client.engine.mock.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import kotlin.test.Test -import kotlin.test.assertEquals - -class PingApiKoinClientTest { - - // Hilfe zur Erstellung eines testbaren Clients mithilfe der neuen DI-freundlichen Implementierung - private fun createTestClient(mockEngine: MockEngine): PingApiKoinClient { - val client = HttpClient(mockEngine) { - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - }) - } - } - return PingApiKoinClient(client) - } - - @Test - fun `simplePing should return correct response`() = runTest { - // Given - val expectedResponse = PingResponse( - status = "OK", - timestamp = "2025-09-27T21:27:00Z", - service = "ping-service" - ) - - val mockEngine = MockEngine { request -> - assertEquals("/api/ping/simple", request.url.encodedPath) - assertEquals(HttpMethod.Get, request.method) - - respond( - content = Json.encodeToString(PingResponse.serializer(), expectedResponse), - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json") - ) - } - - // When - val apiClient = createTestClient(mockEngine) - val response = apiClient.simplePing() - - // Then - assertEquals(expectedResponse, response) - } - - @Test - fun `enhancedPing should include simulate parameter`() = runTest { - // Given - val expectedResponse = EnhancedPingResponse( - status = "OK", - timestamp = "2025-09-27T21:27:00Z", - service = "ping-service", - circuitBreakerState = "CLOSED", - responseTime = 42L - ) - - val mockEngine = MockEngine { request -> - assertEquals("/api/ping/enhanced", request.url.encodedPath) - assertEquals("true", request.url.parameters["simulate"]) - assertEquals(HttpMethod.Get, request.method) - - respond( - content = Json.encodeToString(EnhancedPingResponse.serializer(), expectedResponse), - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json") - ) - } - - // When - val apiClient = createTestClient(mockEngine) - val response = apiClient.enhancedPing(simulate = true) - - // Then - assertEquals(expectedResponse, response) - } - - @Test - fun `healthCheck should return health response`() = runTest { - // Given - val expectedResponse = HealthResponse( - status = "UP", - timestamp = "2025-09-27T21:27:00Z", - service = "ping-service", - healthy = true - ) - - val mockEngine = MockEngine { request -> - assertEquals("/api/ping/health", request.url.encodedPath) - assertEquals(HttpMethod.Get, request.method) - - respond( - content = Json.encodeToString(HealthResponse.serializer(), expectedResponse), - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json") - ) - } - - // When - val apiClient = createTestClient(mockEngine) - val response = apiClient.healthCheck() - - // Then - assertEquals(expectedResponse, response) - } - - @Test - fun `JSON serialization should work correctly`() { - // Given - val pingResponse = PingResponse( - status = "OK", - timestamp = "2025-09-27T21:27:00Z", - service = "test-service" - ) - - // When - val json = Json.encodeToString(PingResponse.serializer(), pingResponse) - val deserializedResponse = Json.decodeFromString(PingResponse.serializer(), json) - - // Then - assertEquals(pingResponse, deserializedResponse) - } - - @Test - fun `Enhanced ping response serialization should work correctly`() { - // Given - val enhancedResponse = EnhancedPingResponse( - status = "OK", - timestamp = "2025-09-27T21:27:00Z", - service = "test-service", - circuitBreakerState = "CLOSED", - responseTime = 123L - ) - - // When - val json = Json.encodeToString(EnhancedPingResponse.serializer(), enhancedResponse) - val deserializedResponse = Json.decodeFromString(EnhancedPingResponse.serializer(), json) - - // Then - assertEquals(enhancedResponse, deserializedResponse) - } - - @Test - fun `Health response serialization should work correctly`() { - // Given - val healthResponse = HealthResponse( - status = "UP", - timestamp = "2025-09-27T21:27:00Z", - service = "test-service", - healthy = true - ) - - // When - val json = Json.encodeToString(HealthResponse.serializer(), healthResponse) - val deserializedResponse = Json.decodeFromString(HealthResponse.serializer(), json) - - // Then - assertEquals(healthResponse, deserializedResponse) - } -} diff --git a/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/integration/PingSyncIntegrationTest.kt b/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/integration/PingSyncIntegrationTest.kt deleted file mode 100644 index e479b70b..00000000 --- a/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/integration/PingSyncIntegrationTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package at.mocode.ping.feature.integration - -import at.mocode.frontend.core.sync.SyncManager -import at.mocode.ping.feature.domain.PingSyncServiceImpl -import at.mocode.ping.feature.test.FakePingEventRepository -import io.ktor.client.* -import io.ktor.client.engine.mock.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class PingSyncIntegrationTest { - - @Test - fun `syncPings should fetch data from API and store in repository`() = runTest { - // Given - val fakeRepo = FakePingEventRepository() - - // Mock API Response - val mockEngine = MockEngine { request -> - assertEquals("/api/ping/sync", request.url.encodedPath) - - val responseBody = """ - [ - { - "id": "event-1", - "message": "Ping 1", - "lastModified": 1000 - }, - { - "id": "event-2", - "message": "Ping 2", - "lastModified": 2000 - } - ] - """.trimIndent() - - respond( - content = responseBody, - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json") - ) - } - - val httpClient = HttpClient(mockEngine) { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - isLenient = true - }) - } - } - - val syncManager = SyncManager(httpClient) - val syncService = PingSyncServiceImpl(syncManager, fakeRepo) - - // When - syncService.syncPings() - - // Then - assertEquals(2, fakeRepo.storedEvents.size) - assertTrue(fakeRepo.storedEvents.any { it.id == "event-1" && it.message == "Ping 1" }) - assertTrue(fakeRepo.storedEvents.any { it.id == "event-2" && it.message == "Ping 2" }) - } -} diff --git a/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/presentation/PingViewModelTest.kt b/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/presentation/PingViewModelTest.kt deleted file mode 100644 index 32f0d24d..00000000 --- a/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/presentation/PingViewModelTest.kt +++ /dev/null @@ -1,312 +0,0 @@ -package at.mocode.ping.feature.presentation - -import at.mocode.ping.api.EnhancedPingResponse -import at.mocode.ping.api.HealthResponse -import at.mocode.ping.api.PingResponse -import at.mocode.ping.feature.test.FakePingSyncService -import at.mocode.ping.feature.test.TestPingApiClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -class PingViewModelTest { - - private lateinit var viewModel: PingViewModel - private lateinit var testApiClient: TestPingApiClient - private lateinit var fakeSyncService: FakePingSyncService - - private val testDispatcher = StandardTestDispatcher() - - @BeforeTest - fun setup() { - Dispatchers.setMain(testDispatcher) - testApiClient = TestPingApiClient() - fakeSyncService = FakePingSyncService() - - viewModel = PingViewModel( - apiClient = testApiClient, - syncService = fakeSyncService - ) - } - - @AfterTest - fun tearDown() { - Dispatchers.resetMain() - testApiClient.reset() - } - - @Test - fun `initial state should be empty`() { - // Given & When - initial state - val initialState = viewModel.uiState - - // Then - assertFalse(initialState.isLoading) - assertNull(initialState.simplePingResponse) - assertNull(initialState.enhancedPingResponse) - assertNull(initialState.healthResponse) - assertNull(initialState.errorMessage) - } - - @Test - fun `performSimplePing should update state with success response`() = runTest(testDispatcher) { - // Given - val expectedResponse = PingResponse( - status = "OK", - timestamp = "2025-09-27T21:27:00Z", - service = "test-service" - ) - testApiClient.simplePingResponse = expectedResponse - - // When - viewModel.performSimplePing() - advanceUntilIdle() - - // Then - val finalState = viewModel.uiState - assertFalse(finalState.isLoading) - assertEquals(expectedResponse, finalState.simplePingResponse) - assertNull(finalState.errorMessage) - assertTrue(testApiClient.simplePingCalled) - } - - @Test - fun `performSimplePing should set loading state during execution`() = runTest(testDispatcher) { - // Given - testApiClient.simulateDelay = true - testApiClient.delayMs = 100 - - // When - viewModel.performSimplePing() - testDispatcher.scheduler.advanceTimeBy(1) // Allow the coroutine to start - - // Then - should be loading during execution - assertTrue(viewModel.uiState.isLoading) - assertNull(viewModel.uiState.errorMessage) - - // When - complete the operation - advanceUntilIdle() - - // Then - should not be loading anymore - assertFalse(viewModel.uiState.isLoading) - } - - @Test - fun `performSimplePing should handle error and update state`() = runTest(testDispatcher) { - // Given - val errorMessage = "Network error" - testApiClient.shouldThrowException = true - testApiClient.exceptionMessage = errorMessage - - // When - viewModel.performSimplePing() - advanceUntilIdle() - - // Then - val finalState = viewModel.uiState - assertFalse(finalState.isLoading) - assertNull(finalState.simplePingResponse) - assertEquals("Simple ping failed: $errorMessage", finalState.errorMessage) - assertTrue(testApiClient.simplePingCalled) - } - - @Test - fun `performEnhancedPing should update state with success response`() = runTest(testDispatcher) { - // Given - val expectedResponse = EnhancedPingResponse( - status = "OK", - timestamp = "2025-09-27T21:27:00Z", - service = "test-service", - circuitBreakerState = "CLOSED", - responseTime = 42L - ) - testApiClient.enhancedPingResponse = expectedResponse - - // When - viewModel.performEnhancedPing(simulate = false) - advanceUntilIdle() - - // Then - val finalState = viewModel.uiState - assertFalse(finalState.isLoading) - assertEquals(expectedResponse, finalState.enhancedPingResponse) - assertNull(finalState.errorMessage) - assertEquals(false, testApiClient.enhancedPingCalledWith) - } - - @Test - fun `performEnhancedPing should handle simulate parameter correctly`() = runTest(testDispatcher) { - // When - viewModel.performEnhancedPing(simulate = true) - advanceUntilIdle() - - // Then - assertEquals(true, testApiClient.enhancedPingCalledWith) - } - - @Test - fun `performEnhancedPing should handle error and update state`() = runTest(testDispatcher) { - // Given - val errorMessage = "Enhanced ping error" - testApiClient.shouldThrowException = true - testApiClient.exceptionMessage = errorMessage - - // When - viewModel.performEnhancedPing() - advanceUntilIdle() - - // Then - val finalState = viewModel.uiState - assertFalse(finalState.isLoading) - assertNull(finalState.enhancedPingResponse) - assertEquals("Enhanced ping failed: $errorMessage", finalState.errorMessage) - } - - @Test - fun `performHealthCheck should update state with success response`() = runTest(testDispatcher) { - // Given - val expectedResponse = HealthResponse( - status = "UP", - timestamp = "2025-09-27T21:27:00Z", - service = "test-service", - healthy = true - ) - testApiClient.healthResponse = expectedResponse - - // When - viewModel.performHealthCheck() - advanceUntilIdle() - - // Then - val finalState = viewModel.uiState - assertFalse(finalState.isLoading) - assertEquals(expectedResponse, finalState.healthResponse) - assertNull(finalState.errorMessage) - assertTrue(testApiClient.healthCheckCalled) - } - - @Test - fun `performHealthCheck should handle error and update state`() = runTest(testDispatcher) { - // Given - val errorMessage = "Health check error" - testApiClient.shouldThrowException = true - testApiClient.exceptionMessage = errorMessage - - // When - viewModel.performHealthCheck() - advanceUntilIdle() - - // Then - val finalState = viewModel.uiState - assertFalse(finalState.isLoading) - assertNull(finalState.healthResponse) - assertEquals("Health check failed: $errorMessage", finalState.errorMessage) - } - - @Test - fun `triggerSync should call syncService and update state`() = runTest(testDispatcher) { - // When - viewModel.triggerSync() - advanceUntilIdle() - - // Then - assertTrue(fakeSyncService.syncPingsCalled) - assertFalse(viewModel.uiState.isSyncing) - assertNotNull(viewModel.uiState.lastSyncResult) - assertNull(viewModel.uiState.errorMessage) - } - - @Test - fun `triggerSync should handle error and update state`() = runTest(testDispatcher) { - // Given - fakeSyncService.shouldThrowException = true - fakeSyncService.exceptionMessage = "Sync failed" - - // When - viewModel.triggerSync() - advanceUntilIdle() - - // Then - assertTrue(fakeSyncService.syncPingsCalled) - assertFalse(viewModel.uiState.isSyncing) - assertEquals("Sync failed: Sync failed", viewModel.uiState.errorMessage) - } - - @Test - fun `clearError should remove error message from state`() { - // Given - set up an error state by simulating an error - testApiClient.shouldThrowException = true - runTest(testDispatcher) { - viewModel.performSimplePing() - advanceUntilIdle() - } - - // Verify error is present - assertNotNull(viewModel.uiState.errorMessage) - - // When - viewModel.clearError() - - // Then - assertNull(viewModel.uiState.errorMessage) - assertFalse(viewModel.uiState.isLoading) - } - - @Test - fun `multiple operations should clear previous error messages`() = runTest(testDispatcher) { - // Given - first operation fails - testApiClient.shouldThrowException = true - viewModel.performSimplePing() - advanceUntilIdle() - assertNotNull(viewModel.uiState.errorMessage) - - // When - second operation succeeds - testApiClient.shouldThrowException = false - val successResponse = PingResponse("SUCCESS", "2025-09-27T21:27:00Z", "test-service") - testApiClient.simplePingResponse = successResponse - viewModel.performSimplePing() - advanceUntilIdle() - - // Then - error should be cleared - assertNull(viewModel.uiState.errorMessage) - assertEquals(successResponse, viewModel.uiState.simplePingResponse) - } - - @Test - fun `loading state should be false after successful operation`() = runTest(testDispatcher) { - // Given - viewModel.performSimplePing() - advanceUntilIdle() - - // Then - assertFalse(viewModel.uiState.isLoading) - } - - @Test - fun `all operations should call respective API methods`() = runTest(testDispatcher) { - // When - viewModel.performSimplePing() - viewModel.performEnhancedPing(true) - viewModel.performHealthCheck() - advanceUntilIdle() - - // Then - assertTrue(testApiClient.simplePingCalled) - assertEquals(true, testApiClient.enhancedPingCalledWith) - assertTrue(testApiClient.healthCheckCalled) - assertEquals(3, testApiClient.callCount) - } -} diff --git a/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/test/Fakes.kt b/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/test/Fakes.kt deleted file mode 100644 index faa5eab7..00000000 --- a/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/ping/feature/test/Fakes.kt +++ /dev/null @@ -1,186 +0,0 @@ -package at.mocode.ping.feature.test - -import at.mocode.frontend.core.sync.SyncableRepository -import at.mocode.ping.api.EnhancedPingResponse -import at.mocode.ping.api.HealthResponse -import at.mocode.ping.api.PingApi -import at.mocode.ping.api.PingEvent -import at.mocode.ping.api.PingResponse -import at.mocode.ping.feature.domain.PingSyncService -import kotlinx.coroutines.delay - -/** - * Fake implementation of PingSyncService for testing. - */ -class FakePingSyncService : PingSyncService { - var syncPingsCalled = false - var shouldThrowException = false - var exceptionMessage = "Sync failed" - - override suspend fun syncPings() { - syncPingsCalled = true - if (shouldThrowException) { - throw Exception(exceptionMessage) - } - } -} - -/** - * Fake implementation of PingEventRepository for testing. - */ -class FakePingEventRepository : SyncableRepository { - var storedEvents = mutableListOf() - var latestSince: String? = null - - override suspend fun getLatestSince(): String? { - return latestSince - } - - override suspend fun upsert(items: List) { - // Simple upsert logic: remove existing with same ID, add new - val ids = items.map { it.id }.toSet() - storedEvents.removeAll { it.id in ids } - storedEvents.addAll(items) - } -} - -/** - * Test double implementation of PingApi for testing purposes. - * This allows us to test ViewModel behavior without needing MockK. - */ -class TestPingApiClient : PingApi { - - // Test configuration properties - var shouldThrowException = false - var exceptionMessage = "Test exception" - var simulateDelay = false - var delayMs = 100L - - // Response configuration - var simplePingResponse: PingResponse? = null - var enhancedPingResponse: EnhancedPingResponse? = null - var healthResponse: HealthResponse? = null - var publicPingResponse: PingResponse? = null - var securePingResponse: PingResponse? = null - var syncPingsResponse: List = emptyList() - - // Call tracking - var simplePingCalled = false - var enhancedPingCalledWith: Boolean? = null - var healthCheckCalled = false - var publicPingCalled = false - var securePingCalled = false - var syncPingsCalledWith: Long? = null - var callCount = 0 - - override suspend fun simplePing(): PingResponse { - simplePingCalled = true - callCount++ - return handleRequest(simplePingResponse) - } - - override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse { - enhancedPingCalledWith = simulate - callCount++ - - if (simulateDelay) { - delay(delayMs) - } - - if (shouldThrowException) { - throw Exception(exceptionMessage) - } - - return enhancedPingResponse ?: EnhancedPingResponse( - status = "OK", - timestamp = "2025-09-27T21:27:00Z", - service = "test-ping-service", - circuitBreakerState = "CLOSED", - responseTime = 42L - ) - } - - override suspend fun healthCheck(): HealthResponse { - healthCheckCalled = true - callCount++ - - if (simulateDelay) { - delay(delayMs) - } - - if (shouldThrowException) { - throw Exception(exceptionMessage) - } - - return healthResponse ?: HealthResponse( - status = "UP", - timestamp = "2025-09-27T21:27:00Z", - service = "test-ping-service", - healthy = true - ) - } - - override suspend fun publicPing(): PingResponse { - publicPingCalled = true - callCount++ - return handleRequest(publicPingResponse) - } - - override suspend fun securePing(): PingResponse { - securePingCalled = true - callCount++ - return handleRequest(securePingResponse) - } - - override suspend fun syncPings(since: Long): List { - syncPingsCalledWith = since - callCount++ - - if (simulateDelay) { - delay(delayMs) - } - - if (shouldThrowException) { - throw Exception(exceptionMessage) - } - - return syncPingsResponse - } - - private suspend fun handleRequest(response: PingResponse?): PingResponse { - if (simulateDelay) { - delay(delayMs) - } - - if (shouldThrowException) { - throw Exception(exceptionMessage) - } - - return response ?: PingResponse( - status = "OK", - timestamp = "2025-09-27T21:27:00Z", - service = "test-ping-service" - ) - } - - // Test utilities - fun reset() { - shouldThrowException = false - exceptionMessage = "Test exception" - simulateDelay = false - delayMs = 100L - simplePingResponse = null - enhancedPingResponse = null - healthResponse = null - publicPingResponse = null - securePingResponse = null - syncPingsResponse = emptyList() - simplePingCalled = false - enhancedPingCalledWith = null - healthCheckCalled = false - publicPingCalled = false - securePingCalled = false - syncPingsCalledWith = null - callCount = 0 - } -} diff --git a/frontend/features/ping-feature/src/jvmMain/kotlin/at/mocode/ping/feature/presentation/PingScreenPreview.kt b/frontend/features/ping-feature/src/jvmMain/kotlin/at/mocode/ping/feature/presentation/PingScreenPreview.kt deleted file mode 100644 index e080489d..00000000 --- a/frontend/features/ping-feature/src/jvmMain/kotlin/at/mocode/ping/feature/presentation/PingScreenPreview.kt +++ /dev/null @@ -1,105 +0,0 @@ -package at.mocode.ping.feature.presentation - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import at.mocode.frontend.core.designsystem.preview.ComponentPreview -import at.mocode.ping.api.* -import at.mocode.ping.feature.domain.PingSyncService - -// ───────────────────────────────────────────────────────────────────────────── -// Fake-Implementierungen für Preview (kein Koin, kein Netzwerk nötig) -// ───────────────────────────────────────────────────────────────────────────── - -private val fakePingResponse = PingResponse( - status = "OK", timestamp = "2026-03-26T12:00:00Z", service = "ping-service" -) - -private val fakeEnhancedResponse = EnhancedPingResponse( - status = "OK", timestamp = "2026-03-26T12:00:00Z", service = "ping-service", - circuitBreakerState = "CLOSED", responseTime = 42L -) - -private val fakeHealthResponse = HealthResponse( - status = "UP", timestamp = "2026-03-26T12:00:00Z", service = "ping-service", healthy = true -) - -private object FakePingApi : PingApi { - override suspend fun simplePing() = fakePingResponse - override suspend fun enhancedPing(simulate: Boolean) = fakeEnhancedResponse - override suspend fun healthCheck() = fakeHealthResponse - override suspend fun publicPing() = fakePingResponse - override suspend fun securePing() = fakePingResponse - override suspend fun syncPings(since: Long): List = emptyList() -} - -private object FakePingSyncService : PingSyncService { - override suspend fun syncPings() { /* no-op */ - } -} - -// Subclass um uiState für Preview direkt setzen zu können -private class PreviewPingViewModel(state: PingUiState) : - PingViewModel(FakePingApi, FakePingSyncService) { - init { - uiState = state - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Previews -// ───────────────────────────────────────────────────────────────────────────── - -@ComponentPreview -@Composable -fun PreviewPingScreen_Empty() { - MaterialTheme { - PingScreen( - viewModel = PreviewPingViewModel(PingUiState()), - onBack = {} - ) - } -} - -@ComponentPreview -@Composable -fun PreviewPingScreen_WithData() { - MaterialTheme { - PingScreen( - viewModel = PreviewPingViewModel( - PingUiState( - simplePingResponse = fakePingResponse, - healthResponse = fakeHealthResponse, - logs = listOf( - LogEntry("12:00:01", "SimplePing", "Success: OK from ping-service"), - LogEntry("12:00:00", "HealthCheck", "Status: UP, Healthy: true"), - ) - ) - ), - onBack = {} - ) - } -} - -@ComponentPreview -@Composable -fun PreviewPingScreen_Loading() { - MaterialTheme { - PingScreen( - viewModel = PreviewPingViewModel(PingUiState(isLoading = true, isSyncing = true)), - onBack = {} - ) - } -} - -@ComponentPreview -@Composable -fun PreviewPingScreen_Error() { - MaterialTheme { - PingScreen( - viewModel = PreviewPingViewModel( - PingUiState(errorMessage = "Connection refused: Backend nicht erreichbar") - ), - onBack = {} - ) - } -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt deleted file mode 100644 index 78114e3f..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/mapper/TurnierMapper.kt +++ /dev/null @@ -1,52 +0,0 @@ -package at.mocode.turnier.feature.data.mapper - -import at.mocode.turnier.feature.data.remote.dto.AbteilungDto -import at.mocode.turnier.feature.data.remote.dto.AbteilungsWarnungDto -import at.mocode.turnier.feature.data.remote.dto.BewerbDto -import at.mocode.turnier.feature.data.remote.dto.TurnierDto -import at.mocode.turnier.feature.domain.Abteilung -import at.mocode.turnier.feature.domain.AbteilungsWarnung -import at.mocode.turnier.feature.domain.Bewerb -import at.mocode.turnier.feature.domain.Turnier - -fun TurnierDto.toDomain(): Turnier = Turnier(id = id, name = name) -fun Turnier.toDto(): TurnierDto = TurnierDto(id = id, name = name) - -fun BewerbDto.toDomain(): Bewerb = Bewerb( - id = id, - turnierId = turnierId, - tag = tag, - platz = platz, - name = name, - sparte = sparte, - klasse = klasse, - nennungen = nennungen, - geplantesDatum = geplantesDatum, - beginnZeit = beginnZeit, - reitdauerMinuten = reitdauerMinuten, - umbauMinuten = umbauMinuten, - besichtigungMinuten = besichtigungMinuten, - austragungsplatzId = austragungsplatzId, - warnungen = warnungen.map { AbteilungsWarnung(it.code, it.nachricht, it.oetoParagraph) } -) - -fun Bewerb.toDto(): BewerbDto = BewerbDto( - id = id, - turnierId = turnierId, - tag = tag, - platz = platz, - name = name, - sparte = sparte, - klasse = klasse, - nennungen = nennungen, - geplantesDatum = geplantesDatum, - beginnZeit = beginnZeit, - reitdauerMinuten = reitdauerMinuten, - umbauMinuten = umbauMinuten, - besichtigungMinuten = besichtigungMinuten, - austragungsplatzId = austragungsplatzId, - warnungen = warnungen.map { AbteilungsWarnungDto(it.code, it.nachricht, it.oetoParagraph) } -) - -fun AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name) -fun Abteilung.toDto(): AbteilungDto = AbteilungDto(id = id, bewerbId = bewerbId, name = name) diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/BewerbApi.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/BewerbApi.kt deleted file mode 100644 index 0eb23ab0..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/BewerbApi.kt +++ /dev/null @@ -1,87 +0,0 @@ -package at.mocode.turnier.feature.data.remote - -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.http.* -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalTime -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class RichterEinsatzDto( - val funktionaerId: String, - val position: String, -) - -@Serializable -data class CreateBewerbPayload( - // Basis - val klasse: String, - val hoeheCm: Int? = null, - val bezeichnung: String, - - // Text & Details - val beschreibung: String? = null, - val aufgabe: String? = null, - val aufgabenNummer: String? = null, - val paraGrade: String? = null, - - // Ort & Funktionäre - val austragungsplatzId: String? = null, - val richterEinsaetze: List = emptyList(), - - // Zeitplan - val geplantesDatum: LocalDate? = null, - @SerialName("beginnZeitTyp") val beginnZeitTyp: String? = null, // enum name - val beginnZeit: LocalTime? = null, - val reitdauerMinuten: Int? = null, - val umbauMinuten: Int? = null, - val besichtigungMinuten: Int? = null, - val stechenGeplant: Boolean = false, - - // Finanzen - val startgeldCent: Long? = null, - val geldpreisAusbezahlt: Boolean = false, -) - -@Serializable -data class BewerbResponse( - val id: String, - val turnierId: String, - val klasse: String, - val hoeheCm: Int? = null, - val bezeichnung: String, - - // Text & Details - val beschreibung: String? = null, - val aufgabe: String? = null, - val aufgabenNummer: String? = null, - val paraGrade: String? = null, - - // Ort & Funktionäre - val austragungsplatzId: String? = null, - val richterEinsaetze: List = emptyList(), - - // Zeitplan - val geplantesDatum: LocalDate? = null, - @SerialName("beginnZeitTyp") val beginnZeitTyp: String? = null, - val beginnZeit: LocalTime? = null, - val reitdauerMinuten: Int? = null, - val umbauMinuten: Int? = null, - val besichtigungMinuten: Int? = null, - val stechenGeplant: Boolean = false, - - // Finanzen - val startgeldCent: Long? = null, - val geldpreisAusbezahlt: Boolean = false, -) - -class BewerbApi(private val apiClient: HttpClient) { - suspend fun createBewerb(turnierId: String, payload: CreateBewerbPayload): BewerbResponse = - apiClient.post("/turniere/$turnierId/bewerbe") { - contentType(ContentType.Application.Json) - setBody(payload) - }.body() -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultAbteilungRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultAbteilungRepository.kt deleted file mode 100644 index 4e17a204..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultAbteilungRepository.kt +++ /dev/null @@ -1,71 +0,0 @@ -package at.mocode.turnier.feature.data.remote - -import at.mocode.frontend.core.network.* -import at.mocode.turnier.feature.data.mapper.toDomain -import at.mocode.turnier.feature.data.mapper.toDto -import at.mocode.turnier.feature.data.remote.dto.AbteilungDto -import at.mocode.turnier.feature.domain.Abteilung -import at.mocode.turnier.feature.domain.AbteilungRepository -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.http.* - -class DefaultAbteilungRepository( - private val client: HttpClient, -) : AbteilungRepository { - - override suspend fun list(bewerbId: Long): Result> = runCatching { - val response = client.get(ApiRoutes.Bewerbe.abteilungen(bewerbId)) - when { - response.status.isSuccess() -> response.body>().map { it.toDomain() } - response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() - response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun getById(id: Long): Result = runCatching { - val response = client.get("${ApiRoutes.API_PREFIX}/abteilungen/$id") - when { - response.status.isSuccess() -> response.body().toDomain() - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() - response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun create(model: Abteilung): Result = runCatching { - val response = client.post(ApiRoutes.Bewerbe.abteilungen(model.bewerbId)) { setBody(model.toDto()) } - when { - response.status.isSuccess() -> response.body().toDomain() - response.status == HttpStatusCode.Conflict -> throw Conflict() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun update(id: Long, model: Abteilung): Result = runCatching { - val response = client.put("${ApiRoutes.API_PREFIX}/abteilungen/$id") { setBody(model.toDto()) } - when { - response.status.isSuccess() -> response.body().toDomain() - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status == HttpStatusCode.Conflict -> throw Conflict() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun delete(id: Long): Result = runCatching { - val response = client.delete("${ApiRoutes.API_PREFIX}/abteilungen/$id") - when { - response.status.isSuccess() -> Unit - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt deleted file mode 100644 index 698aef97..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt +++ /dev/null @@ -1,127 +0,0 @@ -package at.mocode.turnier.feature.data.remote - -import at.mocode.frontend.core.network.* -import at.mocode.turnier.feature.data.mapper.toDomain -import at.mocode.turnier.feature.data.mapper.toDto -import at.mocode.turnier.feature.data.remote.dto.BewerbDto -import at.mocode.turnier.feature.domain.AuditLogEntry -import at.mocode.turnier.feature.domain.Bewerb -import at.mocode.turnier.feature.domain.BewerbRepository -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.http.* - -class DefaultBewerbRepository( - private val client: HttpClient, -) : BewerbRepository { - - override suspend fun list(turnierId: Long): Result> = runCatching { - val response = client.get(ApiRoutes.Turniere.bewerbe(turnierId)) - when { - response.status.isSuccess() -> response.body>().map { it.toDomain() } - response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() - response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun importBewerbe(turnierId: Long, bewerbe: List): Result = - runCatching { - val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") { - setBody(bewerbe) - } - when { - response.status.isSuccess() -> Unit - response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() - response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun getById(id: Long): Result = runCatching { - val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id") - when { - response.status.isSuccess() -> response.body().toDomain() - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() - response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun create(model: Bewerb): Result = runCatching { - val response = client.post(ApiRoutes.Turniere.bewerbe(model.turnierId)) { setBody(model.toDto()) } - when { - response.status.isSuccess() -> response.body().toDomain() - response.status == HttpStatusCode.Conflict -> throw Conflict() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun update(id: Long, model: Bewerb): Result = runCatching { - val response = client.put("${ApiRoutes.API_PREFIX}/bewerbe/$id") { setBody(model.toDto()) } - when { - response.status.isSuccess() -> response.body().toDomain() - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status == HttpStatusCode.Conflict -> throw Conflict() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result = - runCatching { - val response = client.patch("${ApiRoutes.API_PREFIX}/bewerbe/$id/zeitplan") { - contentType(ContentType.Application.Json) - setBody( - mapOf( - "geplantesDatum" to datum, - "beginnZeit" to beginn, - "austragungsplatzId" to platzId - ) - ) - } - when { - response.status.isSuccess() -> response.body().toDomain() - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun getAuditLog(bewerbId: Long): Result> = runCatching { - val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/audit-log") - when { - response.status.isSuccess() -> response.body>() - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun exportZnsBSatz(turnierId: Long): Result = runCatching { - val response = client.get("${ApiRoutes.API_PREFIX}/turniere/$turnierId/export/zns/b-satz") - when { - response.status.isSuccess() -> response.body() - response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() - response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun delete(id: Long): Result = runCatching { - val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id") - when { - response.status.isSuccess() -> Unit - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultErgebnisRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultErgebnisRepository.kt deleted file mode 100644 index 17bce583..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultErgebnisRepository.kt +++ /dev/null @@ -1,40 +0,0 @@ -package at.mocode.turnier.feature.data.remote - -import at.mocode.frontend.core.network.ApiRoutes -import at.mocode.turnier.feature.domain.Ergebnis -import at.mocode.turnier.feature.domain.ErgebnisRepository -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.http.* - -class DefaultErgebnisRepository( - private val client: HttpClient -) : ErgebnisRepository { - - override suspend fun getForBewerb(bewerbId: String): Result> = runCatching { - client.get(ApiRoutes.Results.bewerb(bewerbId)).body() - } - - override suspend fun save(ergebnis: Ergebnis): Result = runCatching { - if (ergebnis.id == null) { - client.post(ApiRoutes.Results.ROOT) { - contentType(ContentType.Application.Json) - setBody(ergebnis) - }.body() - } else { - client.put("${ApiRoutes.Results.ROOT}/${ergebnis.id}") { - contentType(ContentType.Application.Json) - setBody(ergebnis) - }.body() - } - } - - override suspend fun calculatePlatzierung(bewerbId: String): Result> = runCatching { - client.post("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/calculate").body() - } - - override suspend fun exportPdf(bewerbId: String): Result = runCatching { - client.get("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/pdf").body() - } -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultMasterdataRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultMasterdataRepository.kt deleted file mode 100644 index 9e08f571..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultMasterdataRepository.kt +++ /dev/null @@ -1,166 +0,0 @@ -package at.mocode.turnier.feature.data.remote - -import at.mocode.frontend.core.network.ApiRoutes -import at.mocode.turnier.feature.domain.* -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.http.* -import kotlinx.serialization.Serializable - -class DefaultMasterdataRepository( - private val client: HttpClient -) : MasterdataRepository { - - override suspend fun searchReiter(query: String): Result> = runCatching { - val response = client.get("${ApiRoutes.Masterdata.REITER}/search") { - parameter("q", query) - } - if (response.status.isSuccess()) { - // Wir mappen hier manuell, da die Features aktuell keine DTOs exportieren - response.body>().map { it.toDomain() } - } else emptyList() - } - - override suspend fun getReiter(id: String): Result = runCatching { - val response = client.get("${ApiRoutes.Masterdata.REITER}/$id") - if (response.status.isSuccess()) { - response.body().toDomain() - } else throw Exception("Reiter nicht gefunden") - } - - override suspend fun saveReiter(reiter: Reiter): Result = runCatching { - val response = client.put("${ApiRoutes.Masterdata.REITER}/${reiter.id}") { - contentType(ContentType.Application.Json) - setBody(ReiterApiDto( - reiterId = reiter.id, - vorname = reiter.vorname, - nachname = reiter.nachname, - satznummer = reiter.satznummer, - vereinsName = reiter.verein, - feiId = reiter.feiId - )) - } - if (response.status.isSuccess()) { - response.body().toDomain() - } else throw Exception("Fehler beim Speichern des Reiters") - } - - override suspend fun searchPferde(query: String): Result> = runCatching { - val response = client.get("${ApiRoutes.Masterdata.PFERDE}/search") { - parameter("q", query) - } - if (response.status.isSuccess()) { - response.body>().map { it.toDomain() } - } else emptyList() - } - - override suspend fun getPferd(id: String): Result = runCatching { - val response = client.get("${ApiRoutes.Masterdata.PFERDE}/$id") - if (response.status.isSuccess()) { - response.body().toDomain() - } else throw Exception("Pferd nicht gefunden") - } - - override suspend fun savePferd(pferd: Pferd): Result = runCatching { - val response = client.put("${ApiRoutes.Masterdata.PFERDE}/${pferd.id}") { - contentType(ContentType.Application.Json) - setBody(HorseApiDto( - pferdId = pferd.id, - pferdeName = pferd.name, - lebensnummer = pferd.lebensnummer, - geschlecht = "UNBEKANNT", // Fallback - geburtsjahr = pferd.geburtsjahr, - satznummer = pferd.oepsNummer - )) - } - if (response.status.isSuccess()) { - response.body().toDomain() - } else throw Exception("Fehler beim Speichern des Pferdes") - } - - override suspend fun searchFunktionaere(query: String): Result> = runCatching { - val response = client.get("${ApiRoutes.Masterdata.FUNKTIONAERE}/search") { - parameter("q", query) - } - if (response.status.isSuccess()) { - response.body>().map { - Funktionaer(it.funktionaerId, it.name ?: "Unbekannt", it.qualifikationen, it.istAktiv) - } - } else emptyList() - } - - override suspend fun listVereine(): Result> = runCatching { - val response = client.get(ApiRoutes.Masterdata.VEREINE) - if (response.status.isSuccess()) { - response.body>().map { - Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter) - } - } else emptyList() - } - - override suspend fun getVereinById(id: String): Result = runCatching { - val response = client.get("${ApiRoutes.Masterdata.VEREINE}/$id") - if (response.status.isSuccess()) { - val it = response.body() - Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter) - } else throw Exception("Verein nicht gefunden") - } - - // Interne Hilfs-DTOs für das Mapping der Masterdata-API - @Serializable - private data class ReiterApiDto( - val reiterId: String, - val vorname: String, - val nachname: String, - val satznummer: String? = null, - val vereinsName: String? = null, - val feiId: String? = null, - val reiterLizenz: String? = null - ) { - fun toDomain() = Reiter( - id = reiterId, - vorname = vorname, - nachname = nachname, - satznummer = satznummer, - verein = vereinsName, - feiId = feiId, - oepsNummer = satznummer - ) - } - - @Serializable - private data class HorseApiDto( - val pferdId: String, - val pferdeName: String, - val lebensnummer: String? = null, - val geschlecht: String, - val geburtsjahr: Int? = null, - val satznummer: String? = null - ) { - fun toDomain() = Pferd( - id = pferdId, - name = pferdeName, - lebensnummer = lebensnummer ?: "", - geburtsjahr = geburtsjahr, - oepsNummer = satznummer - ) - } - - @Serializable - private data class FunktionaerApiDto( - val funktionaerId: String, - val name: String? = null, - val qualifikationen: List = emptyList(), - val istAktiv: Boolean - ) - - @Serializable - private data class VereinApiDto( - val vereinId: String, - val vereinsNummer: String, - val name: String, - val ort: String? = null, - val istVeranstalter: Boolean - ) -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultNennungRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultNennungRepository.kt deleted file mode 100644 index a4863578..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultNennungRepository.kt +++ /dev/null @@ -1,88 +0,0 @@ -package at.mocode.turnier.feature.data.remote - -import at.mocode.frontend.core.network.* -import at.mocode.turnier.feature.data.remote.dto.* -import at.mocode.turnier.feature.domain.Nennung -import at.mocode.turnier.feature.domain.NennungRepository -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.http.* - -class DefaultNennungRepository( - private val client: HttpClient -) : NennungRepository { - - override suspend fun list(turnierId: Long): Result> = runCatching { - val response = client.get("${ApiRoutes.Turniere.ROOT}/$turnierId/nennungen") - if (response.status.isSuccess()) { - response.body>().map { it.toDomain() } - } else { - throw HttpError(response.status.value) - } - } - - override suspend fun listByBewerb(bewerbId: Long): Result> = runCatching { - val response = client.get(ApiRoutes.Bewerbe.nennungen(bewerbId)) - if (response.status.isSuccess()) { - response.body>().map { it.toDomain() } - } else { - throw HttpError(response.status.value) - } - } - - override suspend fun einreichen(request: NennungEinreichenRequest): Result = runCatching { - val response = client.post(ApiRoutes.Bewerbe.nennungen(request.bewerbId.toLong())) { - contentType(ContentType.Application.Json) - setBody(request) - } - if (response.status.isSuccess()) { - response.body().toDomain() - } else { - throw HttpError(response.status.value) - } - } - - override suspend fun updateStatus(id: String, status: String): Result = runCatching { - val response = client.patch("${ApiRoutes.API_PREFIX}/nennungen/$id/status") { - contentType(ContentType.Application.Json) - setBody(mapOf("status" to status)) - } - if (response.status.isSuccess()) { - response.body().toDomain() - } else { - throw HttpError(response.status.value) - } - } - - override suspend fun delete(id: String): Result = runCatching { - val response = client.delete("${ApiRoutes.API_PREFIX}/nennungen/$id") - if (!response.status.isSuccess()) { - throw HttpError(response.status.value) - } - } - - private fun NennungSummaryDto.toDomain() = Nennung( - id = nennungId, - turnierId = turnierId, - bewerbId = bewerbId, - abteilungId = abteilungId, - reiterId = reiterId, - pferdId = pferdId, - status = status, - istNachnennung = istNachnennung, - createdAt = createdAt - ) - - private fun NennungDetailDto.toDomain() = Nennung( - id = nennungId, - turnierId = turnierId, - bewerbId = bewerbId, - abteilungId = abteilungId, - reiterId = reiterId, - pferdId = pferdId, - status = status, - istNachnennung = istNachnennung, - createdAt = createdAt - ) -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultSeriesRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultSeriesRepository.kt deleted file mode 100644 index 09d57a07..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultSeriesRepository.kt +++ /dev/null @@ -1,41 +0,0 @@ -package at.mocode.turnier.feature.data.remote - -import at.mocode.frontend.core.network.ApiRoutes -import at.mocode.turnier.feature.domain.Serie -import at.mocode.turnier.feature.domain.SerieStandEntry -import at.mocode.turnier.feature.domain.SeriesRepository -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.http.* - -class DefaultSeriesRepository( - private val client: HttpClient -) : SeriesRepository { - - override suspend fun getAll(): Result> = runCatching { - client.get(ApiRoutes.Series.ROOT).body() - } - - override suspend fun getById(id: String): Result = runCatching { - client.get("${ApiRoutes.Series.ROOT}/$id").body() - } - - override suspend fun save(serie: Serie): Result = runCatching { - if (serie.id == null) { - client.post(ApiRoutes.Series.ROOT) { - contentType(ContentType.Application.Json) - setBody(serie) - }.body() - } else { - client.put("${ApiRoutes.Series.ROOT}/${serie.id}") { - contentType(ContentType.Application.Json) - setBody(serie) - }.body() - } - } - - override suspend fun getStand(serieId: String): Result> = runCatching { - client.get(ApiRoutes.Series.stand(serieId)).body() - } -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultStartlistenRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultStartlistenRepository.kt deleted file mode 100644 index fa4b0d97..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultStartlistenRepository.kt +++ /dev/null @@ -1,34 +0,0 @@ -package at.mocode.turnier.feature.data.remote - -import at.mocode.frontend.core.network.* -import at.mocode.turnier.feature.domain.StartlistenRepository -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.http.* -import at.mocode.turnier.feature.domain.model.StartlistenZeile - -class DefaultStartlistenRepository( - private val client: HttpClient, -) : StartlistenRepository { - - override suspend fun generate(bewerbId: Long): Result> = runCatching { - val response = client.post("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/startliste/generate") - when { - response.status.isSuccess() -> response.body() - response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun getByBewerb(bewerbId: Long): Result> = runCatching { - val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/startliste") - when { - response.status.isSuccess() -> response.body() - response.status == HttpStatusCode.NotFound -> emptyList() - response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() - else -> throw HttpError(response.status.value) - } - } -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultTurnierRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultTurnierRepository.kt deleted file mode 100644 index 70577cc0..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultTurnierRepository.kt +++ /dev/null @@ -1,71 +0,0 @@ -package at.mocode.turnier.feature.data.remote - -import at.mocode.frontend.core.network.* -import at.mocode.turnier.feature.data.mapper.toDomain -import at.mocode.turnier.feature.data.mapper.toDto -import at.mocode.turnier.feature.data.remote.dto.TurnierDto -import at.mocode.turnier.feature.domain.Turnier -import at.mocode.turnier.feature.domain.TurnierRepository -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.http.* - -class DefaultTurnierRepository( - private val client: HttpClient, -) : TurnierRepository { - - override suspend fun list(): Result> = runCatching { - val response = client.get(ApiRoutes.Turniere.ROOT) - when { - response.status.isSuccess() -> response.body>().map { it.toDomain() } - response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() - response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun getById(id: Long): Result = runCatching { - val response = client.get("${ApiRoutes.Turniere.ROOT}/$id") - when { - response.status.isSuccess() -> response.body().toDomain() - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status == HttpStatusCode.Unauthorized -> throw AuthExpired() - response.status == HttpStatusCode.Forbidden -> throw AuthForbidden() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun create(model: Turnier): Result = runCatching { - val response = client.post(ApiRoutes.Turniere.ROOT) { setBody(model.toDto()) } - when { - response.status.isSuccess() -> response.body().toDomain() - response.status == HttpStatusCode.Conflict -> throw Conflict() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun update(id: Long, model: Turnier): Result = runCatching { - val response = client.put("${ApiRoutes.Turniere.ROOT}/$id") { setBody(model.toDto()) } - when { - response.status.isSuccess() -> response.body().toDomain() - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status == HttpStatusCode.Conflict -> throw Conflict() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } - - override suspend fun delete(id: Long): Result = runCatching { - val response = client.delete("${ApiRoutes.Turniere.ROOT}/$id") - when { - response.status.isSuccess() -> Unit - response.status == HttpStatusCode.NotFound -> throw NotFound() - response.status.value >= 500 -> throw ServerError() - else -> throw HttpError(response.status.value) - } - } -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/NennungDto.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/NennungDto.kt deleted file mode 100644 index a89f417a..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/NennungDto.kt +++ /dev/null @@ -1,49 +0,0 @@ -package at.mocode.turnier.feature.data.remote.dto - -import kotlinx.serialization.Serializable -import kotlin.uuid.ExperimentalUuidApi - -@OptIn(ExperimentalUuidApi::class) -@Serializable -data class NennungSummaryDto( - val nennungId: String, - val turnierId: String, - val bewerbId: String, - val abteilungId: String, - val reiterId: String, - val pferdId: String, - val status: String, - val istNachnennung: Boolean, - val createdAt: String -) - -@OptIn(ExperimentalUuidApi::class) -@Serializable -data class NennungDetailDto( - val nennungId: String, - val abteilungId: String, - val bewerbId: String, - val turnierId: String, - val reiterId: String, - val pferdId: String, - val zahlerId: String? = null, - val status: String, - val startwunsch: String, - val istNachnennung: Boolean, - val bemerkungen: String? = null, - val createdAt: String, - val updatedAt: String -) - -@Serializable -data class NennungEinreichenRequest( - val abteilungId: String, - val bewerbId: String, - val turnierId: String, - val reiterId: String, - val pferdId: String, - val zahlerId: String? = null, - val startwunsch: String = "KEIN_WUNSCH", - val istNachnennung: Boolean = false, - val bemerkungen: String? = null -) diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt deleted file mode 100644 index ccbb1b8b..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/data/remote/dto/TurnierDto.kt +++ /dev/null @@ -1,42 +0,0 @@ -package at.mocode.turnier.feature.data.remote.dto - -import kotlinx.serialization.Serializable - -@Serializable -data class TurnierDto( - val id: Long, - val name: String, -) - -@Serializable -data class BewerbDto( - val id: Long, - val turnierId: Long, - val tag: String, - val platz: Int, - val name: String, - val sparte: String, - val klasse: String, - val nennungen: Int, - val geplantesDatum: String? = null, - val beginnZeit: String? = null, - val reitdauerMinuten: Int? = null, - val umbauMinuten: Int? = null, - val besichtigungMinuten: Int? = null, - val austragungsplatzId: String? = null, - val warnungen: List = emptyList(), -) - -@Serializable -data class AbteilungsWarnungDto( - val code: String, - val nachricht: String, - val oetoParagraph: String? = null, -) - -@Serializable -data class AbteilungDto( - val id: Long, - val bewerbId: Long, - val name: String, -) diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/AbteilungRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/AbteilungRepository.kt deleted file mode 100644 index 8c1d433a..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/AbteilungRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package at.mocode.turnier.feature.domain - -data class Abteilung( - val id: Long, - val bewerbId: Long, - val name: String, -) - -interface AbteilungRepository { - suspend fun list(bewerbId: Long): Result> - suspend fun getById(id: Long): Result - suspend fun create(model: Abteilung): Result - suspend fun update(id: Long, model: Abteilung): Result - suspend fun delete(id: Long): Result -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt deleted file mode 100644 index 04a31174..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt +++ /dev/null @@ -1,50 +0,0 @@ -package at.mocode.turnier.feature.domain - -import at.mocode.zns.parser.ZnsBewerb - -data class Bewerb( - val id: Long, - val turnierId: Long, - val tag: String, - val platz: Int, - val name: String, - val sparte: String, - val klasse: String, - val nennungen: Int, - val warnungen: List = emptyList(), - // Zeitplan-Felder - val geplantesDatum: String? = null, // ISO-Format - val beginnZeit: String? = null, // "HH:mm" - val reitdauerMinuten: Int? = null, - val umbauMinuten: Int? = null, - val besichtigungMinuten: Int? = null, - val austragungsplatzId: String? = null, -) - -data class AbteilungsWarnung( - val code: String, - val nachricht: String, - val oetoParagraph: String? -) - -data class AuditLogEntry( - val id: String, - val entityType: String, - val entityId: String, - val action: String, - val userId: String?, - val timestamp: String, - val changesJson: String? -) - -interface BewerbRepository { - suspend fun list(turnierId: Long): Result> - suspend fun getById(id: Long): Result - suspend fun create(model: Bewerb): Result - suspend fun update(id: Long, model: Bewerb): Result - suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result - suspend fun getAuditLog(bewerbId: Long): Result> - suspend fun exportZnsBSatz(turnierId: Long): Result - suspend fun delete(id: Long): Result - suspend fun importBewerbe(turnierId: Long, bewerbe: List): Result -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/ErgebnisRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/ErgebnisRepository.kt deleted file mode 100644 index 930df7fd..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/ErgebnisRepository.kt +++ /dev/null @@ -1,27 +0,0 @@ -package at.mocode.turnier.feature.domain - -import kotlinx.serialization.Serializable - -@Serializable -data class Ergebnis( - val id: String? = null, - val nennungId: String, - val bewerbId: String, - val wertnote: Double? = null, - val zeit: Double? = null, - val fehler: Double? = null, - val status: ErgebnisStatus = ErgebnisStatus.OK, - val platzierung: Int? = null -) - -@Serializable -enum class ErgebnisStatus { - OK, AUSGESCHIEDEN, VERZICHTET, DISQUALIFIZIERT, NICHT_GESTARTET -} - -interface ErgebnisRepository { - suspend fun getForBewerb(bewerbId: String): Result> - suspend fun save(ergebnis: Ergebnis): Result - suspend fun calculatePlatzierung(bewerbId: String): Result> - suspend fun exportPdf(bewerbId: String): Result -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/MasterdataRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/MasterdataRepository.kt deleted file mode 100644 index aadbc932..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/MasterdataRepository.kt +++ /dev/null @@ -1,50 +0,0 @@ -package at.mocode.turnier.feature.domain - -data class Reiter( - val id: String, - val vorname: String, - val nachname: String, - val satznummer: String? = null, - val verein: String? = null, - val feiId: String? = null, - val oepsNummer: String? = null -) { - val name: String get() = "$vorname $nachname" -} - -data class Pferd( - val id: String, - val name: String, - val lebensnummer: String, - val geburtsjahr: Int? = null, - val oepsNummer: String? = null -) - -data class Funktionaer( - val id: String, - val name: String, - val qualifikationen: List, - val istAktiv: Boolean -) - -data class Verein( - val id: String, - val name: String, - val vereinsNummer: String, - val ort: String?, - val istVeranstalter: Boolean -) - -interface MasterdataRepository { - suspend fun searchReiter(query: String): Result> - suspend fun getReiter(id: String): Result - suspend fun saveReiter(reiter: Reiter): Result - - suspend fun searchPferde(query: String): Result> - suspend fun getPferd(id: String): Result - suspend fun savePferd(pferd: Pferd): Result - - suspend fun searchFunktionaere(query: String): Result> - suspend fun listVereine(): Result> - suspend fun getVereinById(id: String): Result -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/NennungRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/NennungRepository.kt deleted file mode 100644 index e4db7ba3..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/NennungRepository.kt +++ /dev/null @@ -1,25 +0,0 @@ -package at.mocode.turnier.feature.domain - -data class Nennung( - val id: String, - val turnierId: String, - val bewerbId: String, - val abteilungId: String, - val reiterId: String, - val pferdId: String, - val status: String, - val istNachnennung: Boolean, - val createdAt: String, - // Erweiterte Infos für UI - val reiterName: String? = null, - val pferdeName: String? = null, - val bewerbName: String? = null -) - -interface NennungRepository { - suspend fun list(turnierId: Long): Result> - suspend fun listByBewerb(bewerbId: Long): Result> - suspend fun einreichen(request: at.mocode.turnier.feature.data.remote.dto.NennungEinreichenRequest): Result - suspend fun updateStatus(id: String, status: String): Result - suspend fun delete(id: String): Result -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/SeriesRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/SeriesRepository.kt deleted file mode 100644 index 818ad322..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/SeriesRepository.kt +++ /dev/null @@ -1,40 +0,0 @@ -package at.mocode.turnier.feature.domain - -import kotlinx.serialization.Serializable - -@Serializable -data class Serie( - val id: String? = null, - val name: String, - val beschreibung: String? = null, - val reglementTyp: String = "STREICHER_NORMAL", - val streichresultateCount: Int = 1, - val bindungstyp: String = "PAAR_BINDUNG", - val bewerbIds: Set = emptySet() -) - -@Serializable -data class SerieStandEntry( - val reiterId: String, - val pferdId: String?, - val punkte: Double, - val anzahlWertungen: Int -) - -@Serializable -data class SeriePunkt( - val id: String? = null, - val serieId: String, - val reiterId: String, - val pferdId: String, - val bewerbId: String, - val punkte: Double, - val platzierung: Int -) - -interface SeriesRepository { - suspend fun getAll(): Result> - suspend fun getById(id: String): Result - suspend fun save(serie: Serie): Result - suspend fun getStand(serieId: String): Result> -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/StartlistenRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/StartlistenRepository.kt deleted file mode 100644 index 321e48fe..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/StartlistenRepository.kt +++ /dev/null @@ -1,8 +0,0 @@ -package at.mocode.turnier.feature.domain - -import at.mocode.turnier.feature.domain.model.StartlistenZeile - -interface StartlistenRepository { - suspend fun generate(bewerbId: Long): Result> - suspend fun getByBewerb(bewerbId: Long): Result> -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/TurnierRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/TurnierRepository.kt deleted file mode 100644 index c092c2d6..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/TurnierRepository.kt +++ /dev/null @@ -1,14 +0,0 @@ -package at.mocode.turnier.feature.domain - -data class Turnier( - val id: Long, - val name: String, -) - -interface TurnierRepository { - suspend fun list(): Result> - suspend fun getById(id: Long): Result - suspend fun create(model: Turnier): Result - suspend fun update(id: Long, model: Turnier): Result - suspend fun delete(id: Long): Result -} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/model/StartlistenZeile.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/model/StartlistenZeile.kt deleted file mode 100644 index 22b29d36..00000000 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/model/StartlistenZeile.kt +++ /dev/null @@ -1,13 +0,0 @@ -package at.mocode.turnier.feature.domain.model - -import kotlinx.serialization.Serializable - -@Serializable -data class StartlistenZeile( - val nr: Int, - val zeit: String, - val reiter: String, - val pferd: String, - val wunsch: String, - val nennungId: String = "" -) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/di/TurnierFeatureModule.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/di/TurnierFeatureModule.kt index 6057c60b..83cf03f3 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/di/TurnierFeatureModule.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/di/TurnierFeatureModule.kt @@ -1,12 +1,12 @@ package at.mocode.frontend.features.turnier.di import at.mocode.frontend.core.network.sync.SyncManager -import at.mocode.turnier.feature.data.remote.* -import at.mocode.turnier.feature.domain.AbteilungRepository -import at.mocode.turnier.feature.domain.BewerbRepository -import at.mocode.turnier.feature.domain.StartlistenRepository -import at.mocode.turnier.feature.domain.TurnierRepository -import at.mocode.turnier.feature.presentation.* +import at.mocode.frontend.features.turnier.data.remote.* +import at.mocode.frontend.features.turnier.domain.AbteilungRepository +import at.mocode.frontend.features.turnier.domain.BewerbRepository +import at.mocode.frontend.features.turnier.domain.StartlistenRepository +import at.mocode.frontend.features.turnier.domain.TurnierRepository +import at.mocode.frontend.features.turnier.presentation.* import org.koin.core.qualifier.named import org.koin.dsl.module @@ -16,10 +16,10 @@ actual val turnierFeatureModule = module { single { DefaultBewerbRepository(client = get(qualifier = named("apiClient"))) } single { DefaultAbteilungRepository(client = get(qualifier = named("apiClient"))) } single { DefaultStartlistenRepository(client = get(qualifier = named("apiClient"))) } - single { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) } - single { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) } - single { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) } - single { DefaultSeriesRepository(client = get(qualifier = named("apiClient"))) } + single { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) } + single { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) } + single { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) } + single { DefaultSeriesRepository(client = get(qualifier = named("apiClient"))) } // ViewModels factory { TurnierViewModel(repo = get()) } diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/AbteilungViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/AbteilungViewModel.kt deleted file mode 100644 index 1cb36c63..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/AbteilungViewModel.kt +++ /dev/null @@ -1,132 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -data class StartlistenEintrag( - val startNr: Int, - val reiterName: String, - val pferdeName: String, -) - -data class ErgebnisEintrag( - val startNr: Int, - val punkte: Double?, - val rang: Int?, -) - -data class AbteilungState( - val isLoading: Boolean = false, - val errorMessage: String? = null, - val startliste: List = emptyList(), - val ergebnisse: List = emptyList(), -) - -sealed interface AbteilungIntent { - data class LoadByBewerb(val bewerbId: Long, val abteilungsNr: Int) : AbteilungIntent - data object Refresh : AbteilungIntent - data class UpdateErgebnis(val startNr: Int, val punkte: Double?) : AbteilungIntent - data class ReorderStartliste(val fromIndex: Int, val toIndex: Int) : AbteilungIntent - data object Publish : AbteilungIntent - data object ClearError : AbteilungIntent -} - -interface AbteilungRepository { - suspend fun loadStartliste(bewerbId: Long, abteilungsNr: Int): List - suspend fun loadErgebnisse(bewerbId: Long, abteilungsNr: Int): List - suspend fun saveErgebnis(bewerbId: Long, abteilungsNr: Int, startNr: Int, punkte: Double?) - suspend fun saveStartlistenOrder(bewerbId: Long, abteilungsNr: Int, orderedStartNr: List) - suspend fun publish(bewerbId: Long, abteilungsNr: Int) -} - -class AbteilungViewModel( - private val repo: AbteilungRepository, - private var bewerbId: Long, - private var abteilungsNr: Int, -) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - private val _state = MutableStateFlow(AbteilungState(isLoading = true)) - val state: StateFlow = _state - - init { - send(AbteilungIntent.LoadByBewerb(bewerbId, abteilungsNr)) - } - - fun send(intent: AbteilungIntent) { - when (intent) { - is AbteilungIntent.LoadByBewerb -> { - bewerbId = intent.bewerbId - abteilungsNr = intent.abteilungsNr - load() - } - is AbteilungIntent.Refresh -> load() - is AbteilungIntent.UpdateErgebnis -> updateErgebnis(intent.startNr, intent.punkte) - is AbteilungIntent.ReorderStartliste -> reorder(intent.fromIndex, intent.toIndex) - is AbteilungIntent.Publish -> publish() - is AbteilungIntent.ClearError -> reduce { it.copy(errorMessage = null) } - } - } - - private fun load() { - reduce { it.copy(isLoading = true, errorMessage = null) } - scope.launch { - try { - val start = repo.loadStartliste(bewerbId, abteilungsNr) - val erg = repo.loadErgebnisse(bewerbId, abteilungsNr) - reduce { it.copy(isLoading = false, startliste = start, ergebnisse = erg) } - } catch (t: Throwable) { - reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") } - } - } - } - - private fun updateErgebnis(startNr: Int, punkte: Double?) { - scope.launch { - try { - repo.saveErgebnis(bewerbId, abteilungsNr, startNr, punkte) - // Lokale Spiegelung - val newErg = state.value.ergebnisse.toMutableList() - val idx = newErg.indexOfFirst { it.startNr == startNr } - if (idx >= 0) newErg[idx] = newErg[idx].copy(punkte = punkte) else newErg += ErgebnisEintrag(startNr, punkte, null) - reduce { it.copy(ergebnisse = newErg) } - } catch (t: Throwable) { - reduce { it.copy(errorMessage = t.message ?: "Fehler beim Speichern") } - } - } - } - - private fun reorder(fromIndex: Int, toIndex: Int) { - val list = state.value.startliste.toMutableList() - if (fromIndex in list.indices && toIndex in list.indices) { - val item = list.removeAt(fromIndex) - list.add(toIndex, item) - reduce { it.copy(startliste = list) } - scope.launch { - try { - repo.saveStartlistenOrder(bewerbId, abteilungsNr, list.map { it.startNr }) - } catch (t: Throwable) { - reduce { it.copy(errorMessage = t.message ?: "Fehler beim Speichern der Reihenfolge") } - } - } - } - } - - private fun publish() { - scope.launch { - try { - repo.publish(bewerbId, abteilungsNr) - } catch (t: Throwable) { - reduce { it.copy(errorMessage = t.message ?: "Fehler beim Veröffentlichen") } - } - } - } - - private inline fun reduce(block: (AbteilungState) -> AbteilungState) { - _state.value = block(_state.value) - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/AktorScreens.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/AktorScreens.kt deleted file mode 100644 index f26e7a1d..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/AktorScreens.kt +++ /dev/null @@ -1,73 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import at.mocode.frontend.core.designsystem.models.PlaceholderContent - -/** - * Placeholder-Screens für Akteur-Verwaltung (actor-context). - * Werden in Phase 4/5 mit echten Daten aus dem actor-context befüllt. - */ - -@Composable -fun ReiterScreen() { - Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { - Text("Reiter", style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.height(24.dp)) - PlaceholderContent( - title = "Reiter-Verwaltung", - subtitle = "Satznummer, Lizenzklasse, Sparten-Lizenz – actor-context (Phase 4).", - ) - } -} - -@Composable -fun PferdeScreen() { - Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { - Text("Pferde", style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.height(24.dp)) - PlaceholderContent( - title = "Pferde-Verwaltung", - subtitle = "Lebensnummer, ZNS-Daten, Passbesitzer – actor-context (Phase 4).", - ) - } -} - -@Composable -fun FunktionaereScreen() { - Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { - Text("Funktionäre", style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.height(24.dp)) - PlaceholderContent( - title = "Funktionäre-Verwaltung", - subtitle = "Richter, Parcourschef, Tierarzt – actor-context (Phase 4).", - ) - } -} - -@Composable -fun MeisterschaftenScreen() { - Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { - Text("Meisterschaften", style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.height(24.dp)) - PlaceholderContent( - title = "Meisterschaften", - subtitle = "Konfigurierbare Reglements, Punktesysteme – series-context (Phase 2+).", - ) - } -} - -@Composable -fun CupsScreen() { - Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { - Text("Cups", style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.height(24.dp)) - PlaceholderContent( - title = "Cups & Serien", - subtitle = "Pluggable Berechnungsmodell, Paar-Bindung – series-context (Phase 2+).", - ) - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/BewerbAnlegenViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/BewerbAnlegenViewModel.kt deleted file mode 100644 index 28e5c748..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/BewerbAnlegenViewModel.kt +++ /dev/null @@ -1,88 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -// Abteilungs-Typen gemäß Domain -enum class AbteilungsTyp { - SEPARATE_SIEGEREHRUNG, - ORGANISATORISCH, -} - -// Rider-Klasse für Vorschlagslogik (vereinfachtes Modell) -enum class ReiterKlasse { R1, R2_PLUS } - -data class AbteilungsInput( - val id: Int, - val label: String, - val mitLizenz: Boolean, - val reiterKlasse: ReiterKlasse, -) - -data class BewerbAnlegenState( - val isOpen: Boolean = false, - val bewerbsTyp: String = "", - val abteilungsTyp: AbteilungsTyp = AbteilungsTyp.SEPARATE_SIEGEREHRUNG, - val abteilungen: List = emptyList(), -) - -sealed interface BewerbAnlegenIntent { - data object Open : BewerbAnlegenIntent - data object Close : BewerbAnlegenIntent - data class SetBewerbsTyp(val typ: String) : BewerbAnlegenIntent - data class SetAbteilungsTyp(val typ: AbteilungsTyp) : BewerbAnlegenIntent - data object ApplyAutoSuggestionIfNeeded : BewerbAnlegenIntent -} - -class BewerbAnlegenViewModel { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - private val _state = MutableStateFlow(BewerbAnlegenState()) - val state: StateFlow = _state - - fun send(intent: BewerbAnlegenIntent) { - when (intent) { - is BewerbAnlegenIntent.Open -> reduce { it.copy(isOpen = true) } - is BewerbAnlegenIntent.Close -> reduce { BewerbAnlegenState() } - is BewerbAnlegenIntent.SetBewerbsTyp -> reduce { it.copy(bewerbsTyp = intent.typ) }.also { - // Bei Änderung des Typs gleich prüfen, ob Auto-Vorschlag anzuwenden ist - send(BewerbAnlegenIntent.ApplyAutoSuggestionIfNeeded) - } - is BewerbAnlegenIntent.SetAbteilungsTyp -> reduce { it.copy(abteilungsTyp = intent.typ) } - is BewerbAnlegenIntent.ApplyAutoSuggestionIfNeeded -> applySuggestion() - } - } - - private fun applySuggestion() { - val s = _state.value - val bTyp = s.bewerbsTyp.uppercase() - - val suggestion = when { - bTyp.contains("CSN-C-NEU") -> listOf( - AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1), - AbteilungsInput(2, label = "Abteilung 2: R2+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS), - ) - bTyp.contains("CDN-B") || bTyp.contains("CDNP-B") -> listOf( - AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1), - AbteilungsInput(2, label = "Abteilung 2: R2", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS), - AbteilungsInput(3, label = "Abteilung 3: R3+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS), - ) - bTyp.contains("CSN-B") -> listOf( - AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1), - AbteilungsInput(2, label = "Abteilung 2: R2+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS), - ) - else -> emptyList() - } - - if (suggestion.isNotEmpty()) { - reduce { it.copy(abteilungen = suggestion, abteilungsTyp = AbteilungsTyp.SEPARATE_SIEGEREHRUNG) } - } - } - - private inline fun reduce(block: (BewerbAnlegenState) -> BewerbAnlegenState) { - _state.value = block(_state.value) - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt deleted file mode 100644 index 3eb9f45d..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt +++ /dev/null @@ -1,392 +0,0 @@ -package at.mocode.turnier.feature.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.turnier.feature.domain.Bewerb -import at.mocode.turnier.feature.domain.BewerbRepository -import at.mocode.turnier.feature.domain.StartlistenRepository -import at.mocode.turnier.feature.domain.model.StartlistenZeile -import at.mocode.zns.parser.ZnsBewerb -import at.mocode.zns.parser.ZnsBewerbParser -import at.mocode.zns.parser.ZnsNennung -import at.mocode.zns.parser.ZnsNennungParser -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -typealias BewerbListItem = Bewerb - -data class BewerbState( - val isLoading: Boolean = false, - val searchQuery: String = "", - val list: List = emptyList(), - val filtered: List = emptyList(), - val selectedId: Long? = null, - val errorMessage: String? = null, - val importPreview: List = emptyList(), - val nennungenPreview: List = emptyList(), - val showImportDialog: Boolean = false, - val showStartlistePreview: Boolean = false, - val currentStartliste: List = emptyList(), - val discoveredNodes: List = emptyList(), - val isScanning: Boolean = false, - // Zeitplan-Audit - val auditLog: List = emptyList(), - val isAuditLoading: Boolean = false, - val exportContent: String? = null, - val showExportDialog: Boolean = false, - val ergebnisse: List = emptyList(), - // Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional) - val dialogState: BewerbAnlegenState = BewerbAnlegenState(), - val editingErgebnis: at.mocode.turnier.feature.domain.Ergebnis? = null, - val selectedZeile: StartlistenZeile? = null -) - -sealed interface BewerbIntent { - data object Load : BewerbIntent - data object Refresh : BewerbIntent - data class SearchChanged(val query: String) : BewerbIntent - data class Select(val id: Long?) : BewerbIntent - data object ClearError : BewerbIntent - - // Delegation an Dialog-VM - data object OpenDialog : BewerbIntent - data object CloseDialog : BewerbIntent - data class SetBewerbsTyp(val typ: String) : BewerbIntent - data class SetAbteilungsTyp(val typ: AbteilungsTyp) : BewerbIntent - - data object OpenImportDialog : BewerbIntent - data object CloseImportDialog : BewerbIntent - data class ProcessImportFile(val lines: List) : BewerbIntent - data class ConfirmImport(val turnierId: Long) : BewerbIntent - data object GenerateStartliste : BewerbIntent - data object CloseStartlistePreview : BewerbIntent - data object StartNetworkScan : BewerbIntent - data object StopNetworkScan : BewerbIntent - data object RefreshDiscoveredNodes : BewerbIntent - data class UpdateZeitplan(val id: Long, val beginnZeit: String?) : BewerbIntent - data class LoadAuditLog(val bewerbId: Long) : BewerbIntent - data object ExportZnsBSatz : BewerbIntent - data object CloseExportDialog : BewerbIntent - data object LoadErgebnisse : BewerbIntent - data class OpenErgebnisEdit(val zeile: StartlistenZeile) : BewerbIntent - data object CloseErgebnisEdit : BewerbIntent - data class SaveErgebnis(val ergebnis: at.mocode.turnier.feature.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.turnier.feature.domain.ErgebnisRepository, - private val syncManager: SyncManager? = null, - private val turnierId: Long, -) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - private val _state = MutableStateFlow(BewerbState(isLoading = true)) - val state: StateFlow = _state - - // Interne Instanz des Dialog‑VM (teilen State via Kopie) - private val dialogVm = BewerbAnlegenViewModel() - - init { - send(BewerbIntent.Load) - observeSyncEvents() - } - - private fun observeSyncEvents() { - syncManager?.let { manager -> - scope.launch { - manager.getIncomingEvents().collect { event -> - when (event) { - is DataChangedEvent -> { - if (event.aggregateType == "Bewerb" || event.aggregateType == "Startliste") { - load() // Bei relevanten Änderungen neu laden - } - } - - is PingEvent -> { - // Optional: Heartbeat loggen oder Status anzeigen - } - - else -> {} - } - } - } - - // Auch verbundene Peers beobachten - scope.launch { - manager.getConnectedPeers().collect { peers -> - reduce { - it.copy(discoveredNodes = peers.map { p -> - DiscoveredService("P2P", p, 0) - }) - } - } - } - } - } - - fun send(intent: BewerbIntent) { - when (intent) { - is BewerbIntent.Load, is BewerbIntent.Refresh -> load() - is BewerbIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() } - is BewerbIntent.Select -> { - reduce { it.copy(selectedId = intent.id) } - if (intent.id != null) { - loadErgebnisse() - } - } - - is BewerbIntent.ClearError -> reduce { it.copy(errorMessage = null) } - - is BewerbIntent.OpenDialog -> { - dialogVm.send(BewerbAnlegenIntent.Open) - syncDialogState() - } - - is BewerbIntent.CloseDialog -> { - dialogVm.send(BewerbAnlegenIntent.Close) - syncDialogState() - } - - is BewerbIntent.SetBewerbsTyp -> { - dialogVm.send(BewerbAnlegenIntent.SetBewerbsTyp(intent.typ)) - syncDialogState() - } - - is BewerbIntent.SetAbteilungsTyp -> { - dialogVm.send(BewerbAnlegenIntent.SetAbteilungsTyp(intent.typ)) - syncDialogState() - } - - is BewerbIntent.OpenImportDialog -> _state.value = _state.value.copy(showImportDialog = true) - is BewerbIntent.CloseImportDialog -> _state.value = - _state.value.copy(showImportDialog = false, importPreview = emptyList(), nennungenPreview = emptyList()) - - is BewerbIntent.ProcessImportFile -> { - val bewerbe = intent.lines.mapNotNull { ZnsBewerbParser.parse(it) } - val nennungen = intent.lines.mapNotNull { ZnsNennungParser.parse(it) } - _state.value = _state.value.copy(importPreview = bewerbe, nennungenPreview = nennungen) - } - - is BewerbIntent.ConfirmImport -> { - confirmImport() - } - - is BewerbIntent.GenerateStartliste -> generateStartliste() - is BewerbIntent.CloseStartlistePreview -> reduce { it.copy(showStartlistePreview = false) } - is BewerbIntent.StartNetworkScan -> startScan() - is BewerbIntent.StopNetworkScan -> stopScan() - is BewerbIntent.RefreshDiscoveredNodes -> refreshNodes() - is BewerbIntent.UpdateZeitplan -> updateZeitplan(intent.id, intent.beginnZeit) - is BewerbIntent.LoadAuditLog -> loadAuditLog(intent.bewerbId) - is BewerbIntent.ExportZnsBSatz -> exportZnsBSatz() - is BewerbIntent.CloseExportDialog -> reduce { it.copy(showExportDialog = false, exportContent = null) } - is BewerbIntent.LoadErgebnisse -> loadErgebnisse() - is BewerbIntent.OpenErgebnisEdit -> { - val bewerbId = state.value.selectedId?.toString() ?: "" - reduce { - it.copy( - selectedZeile = intent.zeile, - editingErgebnis = at.mocode.turnier.feature.domain.Ergebnis( - nennungId = intent.zeile.nennungId, - bewerbId = bewerbId - ) - ) - } - } - - is BewerbIntent.CloseErgebnisEdit -> reduce { it.copy(editingErgebnis = null, selectedZeile = null) } - is BewerbIntent.SaveErgebnis -> { - scope.launch { - ergebnisRepo.save(intent.ergebnis).onSuccess { - reduce { it.copy(editingErgebnis = null, selectedZeile = null) } - loadErgebnisse() - } - } - } - - is BewerbIntent.CalculatePlatzierung -> { - val selectedId = state.value.selectedId ?: return - scope.launch { - ergebnisRepo.calculatePlatzierung(selectedId.toString()).onSuccess { - loadErgebnisse() - } - } - } - - is BewerbIntent.ExportErgebnislistePdf -> { - val selectedId = state.value.selectedId ?: return - scope.launch { - ergebnisRepo.exportPdf(selectedId.toString()).onSuccess { bytes -> - // In einer echten Desktop-App würde man hier einen File-Saver öffnen. - // Für den MVP loggen wir nur den Erfolg ein. - println("PDF Export erfolgreich: ${bytes.size} bytes") - } - } - } - } - } - - private fun loadErgebnisse() { - val bewerbId = state.value.selectedId ?: return - scope.launch { - ergebnisRepo.getForBewerb(bewerbId.toString()).onSuccess { list -> - reduce { it.copy(ergebnisse = list) } - } - } - } - - private fun exportZnsBSatz() { - _state.update { it.copy(isLoading = true) } - scope.launch { - repo.exportZnsBSatz(turnierId).onSuccess { content -> - _state.update { it.copy(isLoading = false, showExportDialog = true, exportContent = content) } - }.onFailure { t -> - _state.update { it.copy(isLoading = false, errorMessage = "ZNS-Export fehlgeschlagen: ${t.message}") } - } - } - } - - private fun loadAuditLog(id: Long) { - _state.update { it.copy(isAuditLoading = true) } - scope.launch { - repo.getAuditLog(id).onSuccess { log -> - _state.update { it.copy(auditLog = log, isAuditLoading = false) } - }.onFailure { t -> - _state.update { - it.copy( - isAuditLoading = false, - errorMessage = "Audit-Log konnte nicht geladen werden: ${t.message}" - ) - } - } - } - } - - private fun updateZeitplan(id: Long, beginn: String?) { - scope.launch { - repo.updateZeitplan(id, null, beginn, null).onSuccess { - load() // Neu laden, um Konsistenz zu prüfen - } - } - } - - private fun startScan() { - syncManager?.start(8080) - _state.update { it.copy(isScanning = true) } - // Nach dem Start des Servers ein ConnectivityCheck-Event Broadcasting, um Präsenz zu zeigen - syncManager?.broadcastEvent( - PingEvent( - eventId = turnierId.toString(), - sequenceNumber = 0, - originNodeId = "Client-${(1000..9999).random()}", - createdAt = 0 // In commonMain ohne Clock-Lib erst mal 0 - ) - ) - refreshNodes() - } - - private fun stopScan() { - syncManager?.stop() - _state.update { it.copy(isScanning = false) } - } - - private fun refreshNodes() { - // Da wir jetzt den SyncManager nutzen, könnten wir hier die connectedPeers anzeigen - // oder weiterhin die Entdeckten aus dem internen DiscoveryService des Managers. - // Für dieses MVP zeigen wir einfach an, dass wir scannen. - } - - fun generateStartliste() { - val selectedId = _state.value.selectedId ?: return - reduce { it.copy(isLoading = true) } - - scope.launch { - startlistenRepo.generate(selectedId).onSuccess { list -> - reduce { - it.copy( - isLoading = false, - showStartlistePreview = true, - currentStartliste = list - ) - } - }.onFailure { t -> - reduce { it.copy(isLoading = false, errorMessage = "Startlisten-Generierung fehlgeschlagen: ${t.message}") } - } - } - } - - private fun confirmImport() { - val toImport = _state.value.importPreview - if (toImport.isEmpty()) { - _state.value = _state.value.copy(showImportDialog = false) - return - } - - reduce { it.copy(isLoading = true) } - scope.launch { - val result = repo.importBewerbe(turnierId, toImport) - if (result.isSuccess) { - reduce { it.copy(showImportDialog = false, importPreview = emptyList()) } - load() - } else { - reduce { - it.copy( - isLoading = false, - errorMessage = "Import fehlgeschlagen: ${result.exceptionOrNull()?.message}" - ) - } - } - } - } - - private fun load() { - reduce { it.copy(isLoading = true, errorMessage = null) } - scope.launch { - repo.list(turnierId).onSuccess { items -> - reduce { cur -> - val filtered = filterList(items, cur.searchQuery) - cur.copy(isLoading = false, list = items, filtered = filtered) - } - }.onFailure { t -> - reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Unbekannter Fehler beim Laden") } - } - } - } - - private fun filter() { - val cur = _state.value - val filtered = filterList(cur.list, cur.searchQuery) - reduce { it.copy(filtered = filtered) } - } - - private fun filterList(list: List, query: String): List { - if (query.isBlank()) return list - val q = query.trim() - return list.filter { - it.name.contains(q, ignoreCase = true) || - it.sparte.contains(q, ignoreCase = true) || - it.klasse.contains(q, ignoreCase = true) || - it.tag.contains(q, ignoreCase = true) - } - } - - private fun syncDialogState() { - _state.value = _state.value.copy(dialogState = dialogVm.state.value) - } - - private inline fun reduce(block: (BewerbState) -> BewerbState) { - _state.value = block(_state.value) - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/CreateBewerbWizardScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/CreateBewerbWizardScreen.kt deleted file mode 100644 index b0f964bb..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/CreateBewerbWizardScreen.kt +++ /dev/null @@ -1,316 +0,0 @@ -package at.mocode.turnier.feature.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.turnier.feature.data.remote.CreateBewerbPayload -import at.mocode.turnier.feature.data.remote.RichterEinsatzDto -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalTime - -enum class WizardStep { IDENTIFIKATION, DETAILS_FINANZEN, ORT_ZEIT, RICHTER_TEILUNG } - -data class CreateBewerbWizardState( - // Step 1 - val klasse: String = "", - val hoeheCm: String = "", // UI-Text, wird zu Int? geparst - val bezeichnung: String = "", - - // Step 2 - val beschreibung: String = "", - val aufgabe: String = "", - val startgeld: String = "", // UI-Text, wird zu Long? Cent - val geldpreisAusbezahlt: Boolean = false, - - // Step 3 - val austragungsplatzId: String = "", - val beginnZeitTyp: String = "", // FIX / ANSCHLIESSEND - val geplantesDatum: String = "", // yyyy-MM-dd - val beginnZeit: String = "", // HH:mm - val reitdauerMinuten: String = "", - val umbauMinuten: String = "", - val besichtigungMinuten: String = "", - val stechenGeplant: Boolean = false, - - // Step 4 - val richter: List = emptyList(), - val teilungsTyp: String = "", // Hinweis: aktuell nur UI; Backend-Feld folgt separat -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CreateBewerbWizardScreen( - modifier: Modifier = Modifier, - state: CreateBewerbWizardState, - onStateChange: (CreateBewerbWizardState) -> Unit, - onSubmit: (CreateBewerbPayload) -> Unit, -) { - var selectedTab by remember { mutableStateOf(0) } - val steps = WizardStep.entries.toTypedArray() - - Column(modifier.fillMaxSize().padding(16.dp)) { - Text("Neuen Bewerb anlegen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - - SecondaryTabRow( - selectedTabIndex = selectedTab, - modifier = Modifier, - divider = { HorizontalDivider() } - ) { - Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Identifikation") }) - Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }, text = { Text("Details & Finanzen") }) - Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }, text = { Text("Ort & Zeitplan") }) - Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }, text = { Text("Richter & Teilung") }) - } - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - when (steps[selectedTab]) { - WizardStep.IDENTIFIKATION -> StepIdentifikation(state, onStateChange) - WizardStep.DETAILS_FINANZEN -> StepDetailsFinanzen(state, onStateChange) - WizardStep.ORT_ZEIT -> StepOrtZeit(state, onStateChange) - WizardStep.RICHTER_TEILUNG -> StepRichterTeilung(state, onStateChange) - } - - Spacer(Modifier.height(16.dp)) - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - TextButton(enabled = selectedTab > 0, onClick = { selectedTab-- }) { Text("Zurück") } - Spacer(Modifier.weight(1f)) - if (selectedTab < steps.lastIndex) { - TextButton(onClick = { selectedTab++ }) { Text("Weiter") } - } else { - OutlinedButton(onClick = { - val payload = state.toPayloadOrNull() - if (payload != null) onSubmit(payload) - }) { Text("Bewerb anlegen") } - } - } - } -} - -@Composable -private fun StepIdentifikation(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) { - LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - item { - OutlinedTextField( - value = state.klasse, - onValueChange = { onStateChange(state.copy(klasse = it)) }, - label = { Text("Sparte/Kategorie/Klasse") }, - modifier = Modifier.fillMaxWidth() - ) - } - item { - OutlinedTextField( - value = state.hoeheCm, - onValueChange = { onStateChange(state.copy(hoeheCm = it.filter { ch -> ch.isDigit() })) }, - label = { Text("Höhe (cm)") }, - modifier = Modifier.fillMaxWidth() - ) - } - item { - OutlinedTextField( - value = state.bezeichnung, - onValueChange = { onStateChange(state.copy(bezeichnung = it)) }, - label = { Text("Bezeichnung") }, - modifier = Modifier.fillMaxWidth() - ) - } - } -} - -@Composable -private fun StepDetailsFinanzen(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) { - LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - item { - OutlinedTextField( - value = state.beschreibung, - onValueChange = { onStateChange(state.copy(beschreibung = it)) }, - label = { Text("Beschreibung (optional)") }, - modifier = Modifier.fillMaxWidth() - ) - } - item { - OutlinedTextField( - value = state.aufgabe, - onValueChange = { onStateChange(state.copy(aufgabe = it)) }, - label = { Text("Aufgabe (z.B. R1)") }, - modifier = Modifier.fillMaxWidth() - ) - } - item { - OutlinedTextField( - value = state.startgeld, - onValueChange = { onStateChange(state.copy(startgeld = it.filter { ch -> ch.isDigit() })) }, - label = { Text("Startgeld (Cent)") }, - modifier = Modifier.fillMaxWidth() - ) - } - item { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = state.geldpreisAusbezahlt, onCheckedChange = { onStateChange(state.copy(geldpreisAusbezahlt = it)) }) - Text("Geldpreis ausbezahlt") - } - } - } -} - -@Composable -private fun StepOrtZeit(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) { - LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - item { - OutlinedTextField( - value = state.austragungsplatzId, - onValueChange = { onStateChange(state.copy(austragungsplatzId = it)) }, - label = { Text("Austragungsplatz-ID (optional)") }, - modifier = Modifier.fillMaxWidth() - ) - } - item { - OutlinedTextField( - value = state.beginnZeitTyp, - onValueChange = { onStateChange(state.copy(beginnZeitTyp = it)) }, - label = { Text("Beginn (FIX/ANSCHLIESSEND)") }, - modifier = Modifier.fillMaxWidth() - ) - } - item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = state.geplantesDatum, - onValueChange = { onStateChange(state.copy(geplantesDatum = it)) }, - label = { Text("Datum (yyyy-MM-dd)") }, - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = state.beginnZeit, - onValueChange = { onStateChange(state.copy(beginnZeit = it)) }, - label = { Text("Beginn (HH:mm)") }, - modifier = Modifier.weight(1f) - ) - } - } - item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = state.reitdauerMinuten, - onValueChange = { onStateChange(state.copy(reitdauerMinuten = it.filter { ch -> ch.isDigit() })) }, - label = { Text("Reitdauer (min)") }, - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = state.umbauMinuten, - onValueChange = { onStateChange(state.copy(umbauMinuten = it.filter { ch -> ch.isDigit() })) }, - label = { Text("Umbau (min)") }, - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = state.besichtigungMinuten, - onValueChange = { onStateChange(state.copy(besichtigungMinuten = it.filter { ch -> ch.isDigit() })) }, - label = { Text("Besichtigung (min)") }, - modifier = Modifier.weight(1f) - ) - } - } - item { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = state.stechenGeplant, onCheckedChange = { onStateChange(state.copy(stechenGeplant = it)) }) - Text("Stechen geplant") - } - } - } -} - -@Composable -private fun StepRichterTeilung(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) { - Column(Modifier.fillMaxWidth()) { - // Warn-Logik (mock): Wenn Richter ausgewählt und Position = "C" ohne weiterer Prüfung -> TB-Hinweis - val warnTb = state.richter.isNotEmpty() - if (warnTb) { - Box( - Modifier.fillMaxWidth().background(Color(0xFFFFF8E1)).padding(12.dp) - ) { Text("Hinweis: Richter-Zuweisung erfordert Freigabe durch TB (Qualifikation prüfen)", color = Color(0xFFFFA000)) } - Spacer(Modifier.height(8.dp)) - } - - OutlinedTextField( - value = state.teilungsTyp, - onValueChange = { onStateChange(state.copy(teilungsTyp = it)) }, - label = { Text("Teilungsregel (z.B. MANUELL)") }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(8.dp)) - - // Minimal-UI für das Hinzufügen eines Richters (freie Eingabe von UUID + Position) - var funktionaerId by remember { mutableStateOf("") } - var position by remember { mutableStateOf("") } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField(funktionaerId, { funktionaerId = it }, label = { Text("Funktionär-ID") }, modifier = Modifier.weight(1f)) - OutlinedTextField(position, { position = it }, label = { Text("Position (C/M/…)") }, modifier = Modifier.weight(1f)) - TextButton(onClick = { - if (funktionaerId.isNotBlank() && position.isNotBlank()) { - val list = state.richter + RichterEinsatzDto(funktionaerId = funktionaerId.trim(), position = position.trim()) - onStateChange(state.copy(richter = list)) - funktionaerId = ""; position = "" - } - }) { Text("Hinzufügen") } - } - - Spacer(Modifier.height(8.dp)) - - state.richter.forEachIndexed { idx, r -> - Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("${idx + 1}. ${r.position} – ${r.funktionaerId}") - TextButton(onClick = { - val list = state.richter.toMutableList().also { it.removeAt(idx) } - onStateChange(state.copy(richter = list)) - }) { Text("Entfernen") } - } - } - } -} - -// --- Mapping UI-State -> API-Payload --- -private fun CreateBewerbWizardState.toPayloadOrNull(): CreateBewerbPayload? { - if (klasse.isBlank() || bezeichnung.isBlank()) return null - - val hoehe: Int? = hoeheCm.toIntOrNull() - val startgeldCent: Long? = startgeld.toLongOrNull() - - val datum: LocalDate? = runCatching { if (geplantesDatum.isBlank()) null else LocalDate.parse(geplantesDatum) }.getOrNull() - val zeit: LocalTime? = runCatching { if (beginnZeit.isBlank()) null else LocalTime.parse(beginnZeit) }.getOrNull() - val beginnTyp: String? = beginnZeitTyp.ifBlank { null } - - val reitMin = reitdauerMinuten.toIntOrNull() - val umbauMin = umbauMinuten.toIntOrNull() - val besMin = besichtigungMinuten.toIntOrNull() - - return CreateBewerbPayload( - klasse = klasse.trim(), - hoeheCm = hoehe, - bezeichnung = bezeichnung.trim(), - beschreibung = beschreibung.ifBlank { null }, - aufgabe = aufgabe.ifBlank { null }, - aufgabenNummer = null, - paraGrade = null, - austragungsplatzId = austragungsplatzId.ifBlank { null }, - richterEinsaetze = richter, - geplantesDatum = datum, - beginnZeitTyp = beginnTyp, - beginnZeit = zeit, - reitdauerMinuten = reitMin, - umbauMinuten = umbauMin, - besichtigungMinuten = besMin, - stechenGeplant = stechenGeplant, - startgeldCent = startgeldCent, - geldpreisAusbezahlt = geldpreisAusbezahlt, - ) -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/MasterdataEditDialogs.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/MasterdataEditDialogs.kt deleted file mode 100644 index b5b5c2b8..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/MasterdataEditDialogs.kt +++ /dev/null @@ -1,212 +0,0 @@ -package at.mocode.turnier.feature.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.turnier.feature.domain.Pferd -import at.mocode.turnier.feature.domain.Reiter -import at.mocode.turnier.feature.domain.Ergebnis -import at.mocode.turnier.feature.domain.ErgebnisStatus - -@Composable -fun ErgebnisEditDialog( - ergebnis: Ergebnis, - reiterName: String, - pferdName: String, - onDismiss: () -> Unit, - onSave: (Ergebnis) -> Unit -) { - var wertnote by remember { mutableStateOf(ergebnis.wertnote?.toString() ?: "") } - var zeit by remember { mutableStateOf(ergebnis.zeit?.toString() ?: "") } - var fehler by remember { mutableStateOf(ergebnis.fehler?.toString() ?: "") } - var status by remember { mutableStateOf(ergebnis.status) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Ergebnis erfassen: $reiterName mit $pferdName") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = wertnote, - onValueChange = { wertnote = it }, - label = { Text("Wertnote") } - ) - OutlinedTextField( - value = zeit, - onValueChange = { zeit = it }, - label = { Text("Zeit") } - ) - OutlinedTextField( - value = fehler, - onValueChange = { fehler = it }, - label = { Text("Fehler") } - ) - - Text("Status") - Column { - ErgebnisStatus.entries.forEach { s -> - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = status == s, onClick = { status = s }) - Text(s.name, modifier = Modifier.clickable { status = s }) - } - } - } - } - }, - confirmButton = { - Button(onClick = { - onSave(ergebnis.copy( - wertnote = wertnote.toDoubleOrNull(), - zeit = zeit.toDoubleOrNull(), - fehler = fehler.toDoubleOrNull(), - status = status - )) - }) { - Text("Speichern") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Abbrechen") - } - } - ) -} - -@Composable -fun ReiterEditDialog( - reiter: Reiter, - onDismiss: () -> Unit, - onSave: (Reiter) -> Unit -) { - var vorname by remember { mutableStateOf(reiter.vorname) } - var nachname by remember { mutableStateOf(reiter.nachname) } - var oepsNummer by remember { mutableStateOf(reiter.oepsNummer ?: "") } - var verein by remember { mutableStateOf(reiter.verein ?: "") } - var feiId by remember { mutableStateOf(reiter.feiId ?: "") } - - val isVornameValid = vorname.isNotBlank() - val isNachnameValid = nachname.isNotBlank() - val isOepsValid = oepsNummer.isBlank() || oepsNummer.all { it.isDigit() || it.isLetter() } // Einfache Prüfung - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Reiter bearbeiten") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = vorname, - onValueChange = { vorname = it }, - label = { Text("Vorname*") }, - isError = !isVornameValid - ) - OutlinedTextField( - value = nachname, - onValueChange = { nachname = it }, - label = { Text("Nachname*") }, - isError = !isNachnameValid - ) - OutlinedTextField( - value = oepsNummer, - onValueChange = { oepsNummer = it }, - label = { Text("OEPS-Nr.") }, - isError = !isOepsValid, - supportingText = { if(!isOepsValid) Text("Ungültiges Format") } - ) - OutlinedTextField(value = verein, onValueChange = { verein = it }, label = { Text("Verein") }) - OutlinedTextField(value = feiId, onValueChange = { feiId = it }, label = { Text("FEI-ID") }) - } - }, - confirmButton = { - Button( - onClick = { - onSave(reiter.copy( - vorname = vorname, - nachname = nachname, - oepsNummer = oepsNummer, - satznummer = oepsNummer, - verein = verein, - feiId = feiId - )) - }, - enabled = isVornameValid && isNachnameValid && isOepsValid - ) { - Text("Speichern") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Abbrechen") - } - } - ) -} - -@Composable -fun PferdEditDialog( - pferd: Pferd, - onDismiss: () -> Unit, - onSave: (Pferd) -> Unit -) { - var name by remember { mutableStateOf(pferd.name) } - var lebensnummer by remember { mutableStateOf(pferd.lebensnummer) } - var oepsNummer by remember { mutableStateOf(pferd.oepsNummer ?: "") } - var geburtsjahr by remember { mutableStateOf(pferd.geburtsjahr?.toString() ?: "") } - - val isNameValid = name.isNotBlank() - val isLebensnrValid = lebensnummer.isNotBlank() - val isJahrValid = geburtsjahr.isBlank() || (geburtsjahr.toIntOrNull() != null && geburtsjahr.length == 4) - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Pferd bearbeiten") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name*") }, - isError = !isNameValid - ) - OutlinedTextField( - value = lebensnummer, - onValueChange = { lebensnummer = it }, - label = { Text("Lebensnummer*") }, - isError = !isLebensnrValid - ) - OutlinedTextField(value = oepsNummer, onValueChange = { oepsNummer = it }, label = { Text("OEPS-Nr.") }) - OutlinedTextField( - value = geburtsjahr, - onValueChange = { geburtsjahr = it }, - label = { Text("Geburtsjahr") }, - isError = !isJahrValid, - supportingText = { if(!isJahrValid) Text("4-stellige Jahreszahl") } - ) - } - }, - confirmButton = { - Button( - onClick = { - onSave(pferd.copy( - name = name, - lebensnummer = lebensnummer, - oepsNummer = oepsNummer, - geburtsjahr = geburtsjahr.toIntOrNull() - )) - }, - enabled = isNameValid && isLebensnrValid && isJahrValid - ) { - Text("Speichern") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Abbrechen") - } - } - ) -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesScreen.kt deleted file mode 100644 index 42c002b6..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesScreen.kt +++ /dev/null @@ -1,120 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.koin.compose.viewmodel.koinViewModel - -private val SeriesBlue = Color(0xFF1E3A8A) - -/** - * SERIES-Screen gemäß Vision_03 & Phase 10. - */ -@Composable -fun SeriesScreen( - title: String, - onBack: () -> Unit, - viewModel: SeriesViewModel = koinViewModel() -) { - val state by viewModel.state.collectAsState() - - Column(modifier = Modifier.fillMaxSize()) { - // Toolbar - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text("Konfiguration & Auswertung (Phase 10)", fontSize = 13.sp, color = Color.Gray) - } - Button( - onClick = { viewModel.createSerie("Neuer Cup") }, - colors = ButtonDefaults.buttonColors(containerColor = SeriesBlue) - ) { - Text("Neue Serie anlegen") - } - } - - HorizontalDivider() - - if (state.series.isEmpty()) { - EmptyState(title, onBack) - } else { - SeriesList(state, onSelect = { viewModel.selectSerie(it) }) - } - } -} - -@Composable -private fun SeriesList(state: SeriesState, onSelect: (String) -> Unit) { - Row(Modifier.fillMaxSize()) { - LazyColumn(Modifier.weight(0.4f).padding(16.dp)) { - items(state.series) { serie -> - Card( - onClick = { serie.id?.let { onSelect(it) } }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - ) { - Column(Modifier.padding(12.dp)) { - Text(serie.name, fontWeight = FontWeight.Bold) - Text(serie.reglementTyp, fontSize = 12.sp) - } - } - } - } - VerticalDivider() - Column(Modifier.weight(0.6f).padding(16.dp)) { - Text("Zwischenstand", style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(8.dp)) - state.selectedSerieStand.forEach { entry -> - Row( - Modifier.fillMaxWidth().padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text("Reiter ID: ${entry.reiterId}", fontWeight = FontWeight.Medium) - entry.pferdId?.let { - Text("Pferd ID: $it", fontSize = 11.sp, color = Color.Gray) - } - } - Column(horizontalAlignment = Alignment.End) { - Text("${entry.punkte} Pkt", fontWeight = FontWeight.Bold, color = SeriesBlue) - Text("${entry.anzahlWertungen} Wertungen", fontSize = 10.sp, color = Color.Gray) - } - } - HorizontalDivider(thickness = 0.5.dp, color = Color.LightGray) - } - } - } -} - -@Composable -private fun EmptyState(title: String, onBack: () -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Keine $title konfiguriert", fontSize = 16.sp, fontWeight = FontWeight.Medium) - Spacer(Modifier.height(8.dp)) - Text( - "Verknüpfe Bewerbe zu einer Serie, um Punktestände automatisch zu berechnen.", - fontSize = 13.sp, - color = Color.Gray, - modifier = Modifier.padding(horizontal = 32.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - Spacer(Modifier.height(24.dp)) - OutlinedButton(onClick = onBack) { - Text("Zurück zur Verwaltung") - } - } - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesViewModel.kt deleted file mode 100644 index edd98e4a..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesViewModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import at.mocode.turnier.feature.domain.Serie -import at.mocode.turnier.feature.domain.SerieStandEntry -import at.mocode.turnier.feature.domain.SeriesRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope - -data class SeriesState( - val series: List = emptyList(), - val isLoading: Boolean = false, - val selectedSerieStand: List = emptyList(), - val error: String? = null -) - -class SeriesViewModel( - private val repository: SeriesRepository -) : ViewModel() { - - private val _state = MutableStateFlow(SeriesState()) - val state = _state.asStateFlow() - - init { - loadSeries() - } - - fun loadSeries() { - viewModelScope.launch { - _state.value = _state.value.copy(isLoading = true) - repository.getAll() - .onSuccess { series -> - _state.value = _state.value.copy(series = series, isLoading = false) - } - .onFailure { - _state.value = _state.value.copy(error = it.message, isLoading = false) - } - } - } - - fun selectSerie(id: String) { - viewModelScope.launch { - repository.getStand(id) - .onSuccess { stand -> - _state.value = _state.value.copy(selectedSerieStand = stand) - } - } - } - - fun createSerie(name: String) { - viewModelScope.launch { - repository.save(Serie(name = name)) - .onSuccess { - loadSeries() - } - } - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierAbrechnungTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierAbrechnungTab.kt deleted file mode 100644 index ae934a71..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierAbrechnungTab.kt +++ /dev/null @@ -1,292 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Print -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import at.mocode.frontend.core.designsystem.models.PlaceholderContent -import at.mocode.frontend.features.billing.presentation.BillingScreen -import at.mocode.frontend.features.billing.presentation.BillingViewModel -import org.koin.compose.koinInject - -private val PrimaryBlue = Color(0xFF1E3A8A) -private val AccentBlue = Color(0xFF3B82F6) -private val OffenePostenRot = Color(0xFFDC2626) - -/** - * ABRECHNUNG-Tab im TurnierDetailScreen. - * Gemäß Figma Vision_03 (figma-entwurf_06): - * - Sub-Tabs: BUCHUNGEN | OFFENE POSTEN | RECHNUNG - * - Rechte Sidebar: AUSWAHL | VERKAUF | BUCHUNGEN | ADRESSEN - * - Buchungstabelle: Buchungstext, Soll, Haben, Saldo, Buchen-Checkbox, Rechnung-Checkbox - * - Rechte Sidebar: Suche nach Reiter/Pferd, Zahlungsart, Buchen-Button - */ -@Composable -fun AbrechnungTabContent(veranstaltungId: Long) { - val billingViewModel: BillingViewModel = koinInject() - - BillingScreen( - viewModel = billingViewModel, - veranstaltungId = veranstaltungId, - onBack = {} - ) -} - -/* Alter Inhalt auskommentiert oder entfernt */ -@Composable -private fun LegacyAbrechnungTabContent() { - var subTab by remember { mutableIntStateOf(0) } - var sidebarTab by remember { mutableIntStateOf(2) } // BUCHUNGEN default - val subTabs = listOf("BUCHUNGEN", "OFFENE POSTEN", "RECHNUNG") - val sidebarTabs = listOf("AUSWAHL", "VERKAUF", "BUCHUNGEN", "ADRESSEN") - - // Placeholder-Buchungen - val buchungen = remember { - listOf( - BuchungspositionUiModel("Startgebühr Bewerb 12 - Dressur Kl. A", 25.00, 0.00), - BuchungspositionUiModel("Startgebühr Bewerb 15 - Springen Kl. B", 30.00, 0.00), - BuchungspositionUiModel("Nenngeld", 15.00, 0.00), - BuchungspositionUiModel("Box 3 Tage", 45.00, 0.00), - ) - } - - Row(modifier = Modifier.fillMaxSize()) { - // ── Hauptbereich ───────────────────────────────────────────────────── - Column(modifier = Modifier.weight(1f).fillMaxHeight()) { - // Sub-Tabs - SecondaryTabRow( - selectedTabIndex = subTab, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = Color(0xFF1E3A8A), - ) { - subTabs.forEachIndexed { i, title -> - Tab( - selected = subTab == i, - onClick = { subTab = i }, - text = { - Text( - title, - fontSize = 12.sp, - fontWeight = if (subTab == i) FontWeight.Bold else FontWeight.Normal - ) - }, - ) - } - } - - when (subTab) { - 0 -> BuchungenContent(buchungen) - 1 -> OffenePostenContent() - 2 -> RechnungContent() - } - } - - VerticalDivider() - - // ── Rechte Sidebar ─────────────────────────────────────────────────── - Column(modifier = Modifier.width(320.dp).fillMaxHeight()) { - SecondaryTabRow( - selectedTabIndex = sidebarTab, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = Color(0xFF1E3A8A), - ) { - sidebarTabs.forEachIndexed { i, title -> - Tab( - selected = sidebarTab == i, - onClick = { sidebarTab = i }, - text = { Text(title, fontSize = 11.sp) }, - ) - } - } - when (sidebarTab) { - 2 -> BuchungenSidebar() - else -> PlaceholderContent(title = sidebarTabs[sidebarTab], subtitle = "") - } - } - } -} - -@Composable -private fun BuchungenContent(buchungen: List) { - val gesamtSoll = buchungen.sumOf { it.soll } - val gesamtHaben = buchungen.sumOf { it.haben } - val gesamtSaldo = gesamtSoll - gesamtHaben - - Column(modifier = Modifier.fillMaxSize()) { - // Toolbar - Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) { - Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(Modifier.width(4.dp)) - Text("Aktualisieren", fontSize = 12.sp) - } - OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) { - Text("Übersicht", fontSize = 12.sp) - } - OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) { - Text("Tabelle Leeren", fontSize = 12.sp, color = Color(0xFFEA580C)) - } - OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) { - Text("Pferd aus Liste entfernen", fontSize = 12.sp) - } - } - - // Tabellen-Header - Row( - modifier = Modifier.fillMaxWidth().background(Color(0xFFF3F4F6)).padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text("Buchungstext", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f)) - Text("Soll", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Haben", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Saldo", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Buchen", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp)) - Text("Rechnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp)) - } - HorizontalDivider() - - LazyColumn(modifier = Modifier.weight(1f)) { - items(buchungen) { b -> - val saldo = b.soll - b.haben - var buchen by remember { mutableStateOf(false) } - var rechnung by remember { mutableStateOf(false) } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(b.buchungstext, fontSize = 13.sp, modifier = Modifier.weight(3f)) - Text("%.2f €".format(b.soll), fontSize = 13.sp, modifier = Modifier.weight(1f)) - Text("%.2f €".format(b.haben), fontSize = 13.sp, modifier = Modifier.weight(1f)) - Text( - "%.2f €".format(saldo), - fontSize = 13.sp, - color = if (saldo > 0) OffenePostenRot else Color.Unspecified, - fontWeight = if (saldo > 0) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier.weight(1f), - ) - Checkbox(checked = buchen, onCheckedChange = { buchen = it }, modifier = Modifier.width(60.dp)) - Checkbox(checked = rechnung, onCheckedChange = { rechnung = it }, modifier = Modifier.width(70.dp)) - } - HorizontalDivider(color = Color(0xFFE5E7EB)) - } - } - - // Gesamt-Zeile - HorizontalDivider() - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text("GESAMT", fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(3f)) - Text("%.2f €".format(gesamtSoll), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) - Text("%.2f €".format(gesamtHaben), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) - Text( - "%.2f €".format(gesamtSaldo), - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - color = if (gesamtSaldo > 0) OffenePostenRot else Color.Unspecified, - modifier = Modifier.weight(1f), - ) - } - } -} - -@Composable -private fun BuchungenSidebar() { - var suchtext by remember { mutableStateOf("") } - var zahlungsart by remember { mutableStateOf("BAR") } - - Column(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Nach Reiter oder Pferd", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - OutlinedTextField( - value = suchtext, - onValueChange = { suchtext = it }, - placeholder = { Text("Bitte auswählen...", fontSize = 12.sp) }, - modifier = Modifier.fillMaxWidth().height(44.dp), - singleLine = true, - ) - - HorizontalDivider() - - Text("Buchen:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("0.00 €", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) - Button( - onClick = {}, - enabled = false, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), - ) { Text("Buchen") } - } - - HorizontalDivider() - - Text("Direkt Drucken:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) { - Icon(Icons.Default.Print, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(Modifier.width(4.dp)) - Text("Saldo", fontSize = 12.sp) - } - OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) { - Text("Rechnung", fontSize = 12.sp) - } - } - - HorizontalDivider() - - Text("Zahlungsart:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - listOf("BAR", "Scheck (+30 €)", "Bankomat", "Kreditkarte").forEach { art -> - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = zahlungsart == art, onClick = { zahlungsart = art }) - Text(art, fontSize = 13.sp) - } - } - Button( - onClick = {}, - enabled = false, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), - ) { Text("Gebühr buchen") } - - // Hinweis - Surface( - color = Color(0xFFEFF6FF), - shape = MaterialTheme.shapes.small, - border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)), - ) { - Text( - "💡 Hinweis: Bei Barzahlung werden die Buchungen sofort verarbeitet. Scheck-Zahlungen erfordern eine zusätzliche Gebühr von 30 €.", - fontSize = 11.sp, - color = Color(0xFF1E40AF), - modifier = Modifier.padding(8.dp), - ) - } - } -} - -@Composable -private fun OffenePostenContent() { - PlaceholderContent(title = "Offene Posten", subtitle = "Alle offenen Forderungen …") -} - -@Composable -private fun RechnungContent() { - PlaceholderContent(title = "Rechnung", subtitle = "Rechnungserstellung …") -} - -// --- UI-Modelle --- -data class BuchungspositionUiModel(val buchungstext: String, val soll: Double, val haben: Double) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierArtikelTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierArtikelTab.kt deleted file mode 100644 index 8a09733d..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierArtikelTab.kt +++ /dev/null @@ -1,263 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -private val PrimaryBlue = Color(0xFF1E3A8A) -private val AccentBlue = Color(0xFF3B82F6) -private val DeleteRed = Color(0xFFDC2626) - -/** - * ARTIKEL-Tab im TurnierDetailScreen. - * Gemäß Figma Vision_03 (figma-entwurf_07 / figma-entwurf_08): - * - Nennungen & Gebühren: Nenngebühr, Startgebühr, Sporteuro, Nachnennungsgebühr, Nennungstausch - * - Stallungen & Boxen: Box/Tag, Einstreu, Paddock - * - Zusatzgebühren: dynamische Liste (Bezeichnung, Betrag, Pflicht) - */ -@Composable -fun ArtikelTabContent() { - var nenngebuehr by remember { mutableStateOf("0.00") } - var startgebuehr by remember { mutableStateOf("15.00") } - var sporteuro by remember { mutableStateOf("0.00") } - var nachnennungsgebuehr by remember { mutableStateOf("0.00") } - var nennungstauschGebuehr by remember { mutableStateOf("0.00") } - var boxProTag by remember { mutableStateOf("0.00") } - var einstreuErstEinstreu by remember { mutableStateOf("0.00") } - var einstreuNachlegen by remember { mutableStateOf("0.00") } - var paddockProTag by remember { mutableStateOf("0.00") } - - var zusatzgebuehren by remember { - mutableStateOf( - listOf( - ZusatzgebuehrUiModel("Stromanschluss pro Tag", "5.00", false), - ZusatzgebuehrUiModel("Camping pro Nacht", "10.00", false), - ), - ) - } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // ── Nennungen & Gebühren ───────────────────────────────────────────── - ArtikelSectionCard(title = "Nennungen & Gebühren") { - ArtikelSubSection("Nennungs- und Startgebühren") { - ArtikelFormRow("Nenngebühr pro Pferd/Reiter:", "(Grundgebühr unabhängig von Anzahl Bewerben)") { - EuroTextField(nenngebuehr) { nenngebuehr = it } - } - ArtikelFormRow("Startgebühr pro Bewerb:", "(Pro einzelner Prüfung)") { - EuroTextField(startgebuehr) { startgebuehr = it } - } - ArtikelFormRow("Sporteuro (Beitrag OEPS):", null) { - EuroTextField(sporteuro) { sporteuro = it } - } - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - ArtikelFormRow("Nachnennungsgebühr:", "(Nach Nennschluss)") { - EuroTextField(nachnennungsgebuehr) { nachnennungsgebuehr = it } - } - ArtikelFormRow("Nennungstausch-Gebühr:", "(Pferd- oder Reiter-Wechsel)") { - EuroTextField(nennungstauschGebuehr) { nennungstauschGebuehr = it } - } - } - } - - // ── Stallungen & Boxen ─────────────────────────────────────────────── - ArtikelSectionCard(title = "Stallungen & Boxen") { - ArtikelFormRow("Box pro Tag:", null) { - EuroTextField(boxProTag) { boxProTag = it } - } - ArtikelFormRow("Einstreu (Erst-Einstreu):", null) { - EuroTextField(einstreuErstEinstreu) { einstreuErstEinstreu = it } - } - ArtikelFormRow("Einstreu (Nachlegen):", null) { - EuroTextField(einstreuNachlegen) { einstreuNachlegen = it } - } - ArtikelFormRow("Paddock pro Tag:", null) { - EuroTextField(paddockProTag) { paddockProTag = it } - } - } - - // ── Zusatzgebühren ─────────────────────────────────────────────────── - ArtikelSectionCard( - title = "Zusatzgebühren", - action = { - TextButton(onClick = { - zusatzgebuehren = zusatzgebuehren + ZusatzgebuehrUiModel("", "0.00", false) - }) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue) - Spacer(Modifier.width(4.dp)) - Text("Hinzufügen", color = AccentBlue, fontSize = 13.sp) - } - }, - ) { - // Tabellen-Header - Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) { - Text("Bezeichnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f)) - Text("Betrag", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f)) - Text("Pflicht", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Spacer(Modifier.width(44.dp)) - } - HorizontalDivider() - zusatzgebuehren.forEachIndexed { idx, z -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = z.bezeichnung, - onValueChange = { v -> - zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(bezeichnung = v) } - }, - modifier = Modifier.weight(3f).height(44.dp).padding(end = 8.dp), - singleLine = true, - ) - OutlinedTextField( - value = z.betrag, - onValueChange = { v -> - zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(betrag = v) } - }, - suffix = { Text("€") }, - modifier = Modifier.weight(1.5f).height(44.dp).padding(end = 8.dp), - singleLine = true, - ) - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = z.pflicht, - onCheckedChange = { v -> - zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(pflicht = v) } - }, - ) - Text("Pflicht", fontSize = 12.sp) - } - IconButton( - onClick = { zusatzgebuehren = zusatzgebuehren.toMutableList().also { it.removeAt(idx) } }, - modifier = Modifier.size(44.dp), - ) { - Icon( - Icons.Default.Delete, - contentDescription = "Löschen", - tint = DeleteRed, - modifier = Modifier.size(18.dp) - ) - } - } - } - } - - // ── Hinweis ────────────────────────────────────────────────────────── - Surface( - modifier = Modifier.fillMaxWidth(), - color = Color(0xFFFFFBEB), - shape = MaterialTheme.shapes.small, - border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFFDE68A)), - ) { - Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text("ℹ️", fontSize = 14.sp) - Column { - Text("Hinweis zur Preisliste", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - Text( - "Die Gebührenstruktur wird in der offiziellen Ausschreibung veröffentlicht und ist für alle Teilnehmer verbindlich. " + - "Bei nationalen Turnieren der Kategorie C-Neu sind oft reduzierte Gebühren oder Gebührenbefreiungen üblich (z.B. kein Nenngeld, kein Sporteuro).", - fontSize = 12.sp, - color = Color(0xFF92400E), - ) - } - } - } - - // ── Aktions-Buttons ────────────────────────────────────────────────── - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - OutlinedButton(onClick = {}) { Text("Zurücksetzen") } - Spacer(Modifier.width(8.dp)) - Button(onClick = {}, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)) { Text("Speichern") } - } - } -} - -@Composable -private fun ArtikelSectionCard( - title: String, - action: @Composable (() -> Unit)? = null, - content: @Composable ColumnScope.() -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = Color.White), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), - ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue) - action?.invoke() - } - content() - } - } -} - -@Composable -private fun ArtikelSubSection(title: String, content: @Composable ColumnScope.() -> Unit) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = Color(0xFFF9FAFB)), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - content() - } - } -} - -@Composable -private fun ArtikelFormRow(label: String, hint: String?, content: @Composable RowScope.() -> Unit) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(label, fontSize = 13.sp, modifier = Modifier.width(220.dp), color = Color(0xFF374151)) - content() - if (hint != null) { - Spacer(Modifier.width(8.dp)) - Text( - hint, - fontSize = 11.sp, - color = Color(0xFF9CA3AF), - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - ) - } - } -} - -@Composable -private fun EuroTextField(value: String, onValueChange: (String) -> Unit) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - suffix = { Text("€") }, - modifier = Modifier.width(120.dp).height(44.dp), - singleLine = true, - ) -} - -// --- UI-Modelle --- -data class ZusatzgebuehrUiModel(val bezeichnung: String, val betrag: String, val pflicht: Boolean) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt deleted file mode 100644 index 0c366eb0..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt +++ /dev/null @@ -1,1030 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import at.mocode.turnier.feature.domain.model.StartlistenZeile -import javax.swing.JFileChooser -import javax.swing.filechooser.FileNameExtensionFilter -import kotlin.time.Duration.Companion.milliseconds - -private val PrimaryBlue = Color(0xFF1E3A8A) -private val HeaderBg = Color(0xFFF1F5F9) -private val SelectedRowBg = Color(0xFFEFF6FF) - -/** - * BEWERBE-Tab gemäß Vision_03 (Screenshots 09–12). - * - * Layout: 3-spaltig - * - Links (140dp): Aktions-Buttons (Speichern, Rückgängig, Einfügen, Löschen, Teilen, Verschieben, Startliste, Ergebnisliste) - * - Mitte (flex): Datentabelle (Tag | Platz | Bewerb | Beginn | Ende | Bewerbname | ZNS | Nennungen) - * - Rechts (340dp): Detail-Panel mit Sub-Tabs (Bewerb | Bewertung | Geldpreise | Ort/Zeit) - */ -@Composable -fun BewerbeTabContent( - viewModel: BewerbViewModel, - turnierId: Long, -) { - val state by viewModel.state.collectAsState() - - // Polling für entdeckte Dienste, wenn Scan aktiv ist - LaunchedEffect(state.isScanning) { - if (state.isScanning) { - while (true) { - viewModel.send(BewerbIntent.RefreshDiscoveredNodes) - kotlinx.coroutines.delay(2000.milliseconds) - } - } - } - - // Dialog-ViewModel für "Bewerb anlegen" - val bewerbDialogVm = remember { BewerbAnlegenViewModel() } - val bewerbDialogState by bewerbDialogVm.state.collectAsState() - - Row(modifier = Modifier.fillMaxSize()) { - // ── Linke Aktions-Spalte ────────────────────────────────────────────── - BewerbeAktionsSpalte( - modifier = Modifier.width(140.dp).fillMaxHeight(), - onBewerbEinfuegen = { bewerbDialogVm.send(BewerbAnlegenIntent.Open) }, - onZnsImport = { - val fileChooser = JFileChooser().apply { - fileFilter = FileNameExtensionFilter("ZNS Nennungs-Dateien (*.dat)", "dat") - } - val result = fileChooser.showOpenDialog(null) - if (result == JFileChooser.APPROVE_OPTION) { - val file = fileChooser.selectedFile - val lines = file.readLines(Charsets.ISO_8859_1) - viewModel.send(BewerbIntent.ProcessImportFile(lines)) - viewModel.send(BewerbIntent.OpenImportDialog) - } - }, - onGenerateStartliste = { viewModel.send(BewerbIntent.GenerateStartliste) }, - onToggleScan = { - if (state.isScanning) viewModel.send(BewerbIntent.StopNetworkScan) - else viewModel.send(BewerbIntent.StartNetworkScan) - }, - isScanning = state.isScanning - ) - VerticalDivider() - - // ── Mittlere Tabelle ────────────────────────────────────────────────── - Column(modifier = Modifier.weight(1f).fillMaxHeight()) { - // Toolbar über der Tabelle - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - onClick = { viewModel.send(BewerbIntent.Refresh) }, - modifier = Modifier.height(32.dp), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), - ) { - Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(Modifier.width(4.dp)) - Text("Aktualisieren", fontSize = 12.sp) - } - Surface( - shape = MaterialTheme.shapes.small, - color = PrimaryBlue, - ) { - Text( - text = "${state.list.size} Bewerbe", - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - ) - } - // Suchfeld - OutlinedTextField( - value = state.searchQuery, - onValueChange = { viewModel.send(BewerbIntent.SearchChanged(it)) }, - modifier = Modifier.weight(1f).height(48.dp), - placeholder = { Text("Suche...", fontSize = 12.sp) }, - singleLine = true, - textStyle = TextStyle(fontSize = 12.sp), - ) - } - - // Tabellen-Header - BewerbeTableHeader() - HorizontalDivider() - - // Tabellen-Zeilen - LazyColumn(modifier = Modifier.fillMaxSize()) { - itemsIndexed(state.filtered) { _, item -> - BewerbeTableRow( - bewerb = item.toUiModel(), - isSelected = state.selectedId == item.id, - onClick = { viewModel.send(BewerbIntent.Select(item.id)) }, - ) - HorizontalDivider(color = Color(0xFFE5E7EB)) - } - } - } - - VerticalDivider() - - // ── Rechtes Detail-Panel ────────────────────────────────────────────── - val selectedItem = state.list.find { it.id == state.selectedId } - Column(modifier = Modifier.width(340.dp).fillMaxHeight()) { - BewerbeDetailPanel( - bewerb = selectedItem?.toUiModel(), - modifier = Modifier.weight(1f), - ) - - if (state.isScanning || state.discoveredNodes.isNotEmpty()) { - HorizontalDivider() - NetworkDiscoveryPanel( - nodes = state.discoveredNodes, - isScanning = state.isScanning - ) - } - } - } - - if (bewerbDialogState.isOpen) { - BewerbAnlegenDialog( - state = bewerbDialogState, - onDismiss = { bewerbDialogVm.send(BewerbAnlegenIntent.Close) }, - onChangeTyp = { - bewerbDialogVm.send(BewerbAnlegenIntent.SetBewerbsTyp(it)) - }, - onChangeAbteilungsTyp = { - bewerbDialogVm.send(BewerbAnlegenIntent.SetAbteilungsTyp(it)) - }, - onCreate = { - // Prototyp: Noch keine Persistenz – nur schließen - bewerbDialogVm.send(BewerbAnlegenIntent.Close) - }, - ) - } - - if (state.showImportDialog) { - ZnsImportPreviewDialog( - bewerbe = state.importPreview, - nennungen = state.nennungenPreview, - onDismiss = { viewModel.send(BewerbIntent.CloseImportDialog) }, - onConfirm = { viewModel.send(BewerbIntent.ConfirmImport(turnierId)) } - ) - } - - if (state.showStartlistePreview) { - StartlistePreviewDialog( - eintraege = state.currentStartliste, - onDismiss = { viewModel.send(BewerbIntent.CloseStartlistePreview) } - ) - } -} - -@Composable -private fun StartlistePreviewDialog( - eintraege: List, - onDismiss: () -> Unit, -) { - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surface, - modifier = Modifier.width(700.dp).heightIn(max = 600.dp) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text("Startliste Vorschau", style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.height(12.dp)) - - Box(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(1.dp)) { - LazyColumn(modifier = Modifier.fillMaxSize()) { - item { - Row(Modifier.fillMaxWidth().background(Color(0xFFF3F4F6)).padding(8.dp)) { - Text("Nr", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Zeit", modifier = Modifier.width(60.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Reiter", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Pferd", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Wunsch", modifier = Modifier.width(80.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) - } - } - items(eintraege) { e: StartlistenZeile -> - Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp)) { - Text(e.nr.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp) - Text(e.zeit, modifier = Modifier.width(60.dp), fontSize = 12.sp) - Text(e.reiter, modifier = Modifier.weight(1f), fontSize = 12.sp) - Text(e.pferd, modifier = Modifier.weight(1f), fontSize = 12.sp) - Text( - text = e.wunsch, - modifier = Modifier.width(80.dp), - fontSize = 11.sp, - color = when (e.wunsch) { - "VORNE" -> Color(0xFF059669) - "HINTEN" -> Color(0xFFDC2626) - else -> Color.Gray - } - ) - } - HorizontalDivider(color = Color.LightGray.copy(alpha = 0.3f)) - } - } - } - - Spacer(Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - OutlinedButton(onClick = onDismiss) { Text("Abbrechen") } - Spacer(Modifier.width(8.dp)) - Button(onClick = { /* TODO: Speichern Logik */ }) { Text("Startliste bestätigen") } - } - } - } - } -} - -@Composable -private fun BewerbeTableHeader() { - Row( - modifier = Modifier - .fillMaxWidth() - .background(HeaderBg) - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TableHeaderCell("Tag", 90.dp) - TableHeaderCell("Platz", 50.dp) - TableHeaderCell("Bewerb", 55.dp) - TableHeaderCell("Beginn", 55.dp) - TableHeaderCell("Ende", 55.dp) - TableHeaderCell("Bewerbname", weight = 1f) - TableHeaderCell("ZNS", 45.dp) - TableHeaderCell("Nennungen", 75.dp) - } -} - -@Composable -private fun RowScope.TableHeaderCell(text: String, width: androidx.compose.ui.unit.Dp? = null, weight: Float? = null) { - val mod = when { - weight != null -> Modifier.weight(weight) - width != null -> Modifier.width(width) - else -> Modifier - } - Text( - text = text, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFF374151), - modifier = mod, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BewerbeTableRow(bewerb: BewerbUiModel, isSelected: Boolean, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(if (isSelected) SelectedRowBg else Color.Transparent) - .clickable(onClick = onClick) - .padding(horizontal = 8.dp, vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(bewerb.tag, fontSize = 12.sp, modifier = Modifier.width(90.dp)) - Text("${bewerb.platz}", fontSize = 12.sp, modifier = Modifier.width(50.dp)) - Text( - "${bewerb.nummer}", - fontSize = 12.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - modifier = Modifier.width(55.dp), - color = if (isSelected) PrimaryBlue else Color.Unspecified - ) - Text(bewerb.beginn, fontSize = 12.sp, modifier = Modifier.width(55.dp)) - Text(bewerb.ende, fontSize = 12.sp, modifier = Modifier.width(55.dp)) - Text(bewerb.name, fontSize = 12.sp, modifier = Modifier.weight(1f), maxLines = 2) - if (bewerb.warnungen.isNotEmpty()) { - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - positioning = TooltipAnchorPosition.Above - ), - tooltip = { - PlainTooltip { - Column { - bewerb.warnungen.forEach { warnung -> - Text("${warnung.oetoParagraph ?: ""}: ${warnung.nachricht}", fontSize = 11.sp) - } - } - } - }, - state = rememberTooltipState(), - ) { - Icon( - Icons.Default.Warning, - contentDescription = "Warnungen vorhanden", - modifier = Modifier.padding(horizontal = 4.dp).size(16.dp), - tint = Color(0xFFFACC15) // Yellow-400 - ) - } - } else { - Spacer(Modifier.width(24.dp)) - } - Text("${bewerb.zns}", fontSize = 12.sp, modifier = Modifier.width(45.dp)) - Text("${bewerb.nennungen}", fontSize = 12.sp, modifier = Modifier.width(75.dp)) - } -} - -@Composable -private fun BewerbeAktionsSpalte( - modifier: Modifier = Modifier, - onBewerbEinfuegen: () -> Unit = {}, - onZnsImport: () -> Unit = {}, - onGenerateStartliste: () -> Unit = {}, - onToggleScan: () -> Unit = {}, - isScanning: Boolean = false, -) { - Column( - modifier = modifier.padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - AktionsBtn("Änderungen\nSpeichern") - AktionsBtn("Änderungen\nRückgängig") - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - AktionsBtn("Bewerb\nEinfügen", onClick = onBewerbEinfuegen) - AktionsBtn("ZNS Import", onClick = onZnsImport) - AktionsBtn("Bewerb\nLöschen") - AktionsBtn("Bewerb Teilen") - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - AktionsBtn("Bewerb nach\noben verschieben") - AktionsBtn("Bewerb nach\nunten verschieben") - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - AktionsBtn( - label = if (isScanning) "Netzwerk-Scan\nStoppen" else "Netzwerk-Scan\nStarten", - onClick = onToggleScan - ) - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - AktionsBtn("Startliste\nGenerieren", onClick = onGenerateStartliste) - AktionsBtn("Startliste\nBearbeiten") - AktionsBtn("Startliste\nDrucken") - AktionsBtn("Ergebnisliste\nBearbeiten") - AktionsBtn("Ergebnisliste\nDrucken") - } -} - -@Composable -private fun NetworkDiscoveryPanel( - nodes: List, - isScanning: Boolean -) { - Column( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .padding(8.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Netzwerk (P2P)", style = MaterialTheme.typography.titleSmall, color = PrimaryBlue) - if (isScanning) { - Spacer(Modifier.width(8.dp)) - CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 2.dp) - } - } - Spacer(Modifier.height(4.dp)) - if (nodes.isEmpty()) { - Text("Keine Instanzen gefunden", fontSize = 11.sp, color = Color.Gray) - } else { - LazyColumn { - items(nodes) { node -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp), tint = PrimaryBlue) - Spacer(Modifier.width(8.dp)) - Column { - Text(node.name, fontSize = 12.sp, fontWeight = FontWeight.Bold) - Text("${node.host}:${node.port}", fontSize = 10.sp, color = Color.Gray) - } - } - } - } - } - } -} - -@Composable -private fun AktionsBtn(label: String, onClick: () -> Unit = {}) { - OutlinedButton( - onClick = onClick, - modifier = Modifier.fillMaxWidth().height(48.dp), - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp), - ) { - Text(label, fontSize = 11.sp, lineHeight = 13.sp) - } -} - -@Composable -private fun ZnsImportPreviewDialog( - bewerbe: List, - nennungen: List = emptyList(), - onDismiss: () -> Unit, - onConfirm: () -> Unit, -) { - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surface, - modifier = Modifier.width(700.dp).heightIn(max = 600.dp) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text("ZNS Bewerbe & Nennungen Import", style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.height(8.dp)) - Text( - "Gefunden: ${bewerbe.size} Bewerbe, ${nennungen.size} Nennungen", - fontSize = 14.sp, - color = Color.Gray - ) - Spacer(Modifier.height(12.dp)) - - Box(modifier = Modifier.weight(1f).background(HeaderBg).padding(2.dp)) { - LazyColumn(modifier = Modifier.fillMaxSize()) { - item { - Row(Modifier.fillMaxWidth().background(Color.LightGray).padding(4.dp)) { - Text("Nr", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Abt", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Name", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Kl", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Kat", modifier = Modifier.width(80.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Nenn", modifier = Modifier.width(50.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp, textAlign = TextAlign.End) - } - } - itemsIndexed(bewerbe) { _, b -> - val count = nennungen.count { it.bewerbNummer == b.bewerbNummer && it.abteilung == b.abteilung } - Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp)) { - Text(b.bewerbNummer.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp) - Text(b.abteilung.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp) - Text(b.name, modifier = Modifier.weight(1f), fontSize = 12.sp) - Text(b.klasse, modifier = Modifier.width(40.dp), fontSize = 12.sp) - Text(b.kategorie, modifier = Modifier.width(80.dp), fontSize = 12.sp) - Text(count.toString(), modifier = Modifier.width(50.dp), fontSize = 12.sp, textAlign = TextAlign.End, fontWeight = FontWeight.Bold) - } - HorizontalDivider(color = Color.LightGray.copy(alpha = 0.5f)) - } - } - } - - Spacer(Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - OutlinedButton(onClick = onDismiss) { Text("Abbrechen") } - Spacer(Modifier.width(8.dp)) - Button(onClick = onConfirm) { - Text("Import bestätigen") - } - } - } - } - } -} - -// Hilfs-Extension -private fun BewerbListItem.toUiModel() = BewerbUiModel( - tag = tag, - platz = platz, - nummer = 0, // In der Liste oft 0, da über ID referenziert - beginn = "", - ende = "", - name = name, - bezeichnung = "$sparte $klasse", - typ = "", - zeile1 = "", - zns = 1, - nennungen = nennungen, - warnungen = warnungen -) - -@Composable -private fun BewerbAnlegenDialog( - state: BewerbAnlegenState, - onDismiss: () -> Unit, - onChangeTyp: (String) -> Unit, - onChangeAbteilungsTyp: (AbteilungsTyp) -> Unit, - onCreate: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Bewerb anlegen") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - // Bewerbs-Typ - Column { - Text("Bewerbs-Typ", fontSize = 12.sp, color = Color(0xFF6B7280)) - OutlinedTextField( - value = state.bewerbsTyp, - onValueChange = onChangeTyp, - placeholder = { Text("z.B. CSN-C-NEU") }, - singleLine = true, - ) - if (state.bewerbsTyp.equals("CSN-C-NEU", ignoreCase = true)) { - AssistChip(onClick = {}, label = { Text("Pflicht-Teilung vorgeschlagen") }) - } - } - - // Abteilungs-Typ Auswahl - Column { - Text("Abteilungs-Typ", fontSize = 12.sp, color = Color(0xFF6B7280)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip( - selected = state.abteilungsTyp == AbteilungsTyp.SEPARATE_SIEGEREHRUNG, - onClick = { onChangeAbteilungsTyp(AbteilungsTyp.SEPARATE_SIEGEREHRUNG) }, - label = { Text("SEPARATE_SIEGEREHRUNG") }, - ) - FilterChip( - selected = state.abteilungsTyp == AbteilungsTyp.ORGANISATORISCH, - onClick = { onChangeAbteilungsTyp(AbteilungsTyp.ORGANISATORISCH) }, - label = { Text("ORGANISATORISCH") }, - ) - } - } - - // Abteilungen (Vorschlag / Liste) - Column { - Text("Abteilungen", fontSize = 12.sp, color = Color(0xFF6B7280)) - if (state.abteilungen.isEmpty()) { - Text("Noch keine Abteilungen. Wähle einen Typ (z.B. CSN-C-NEU) für Vorschlag.", fontSize = 12.sp) - } else { - state.abteilungen.forEach { a -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(a.label, fontSize = 13.sp) - val lizenz = if (a.mitLizenz) "mit Lizenz" else "ohne Lizenz" - Text("${lizenz} · ${if (a.reiterKlasse == ReiterKlasse.R1) "R1" else "R2+"}", fontSize = 12.sp, color = Color(0xFF6B7280)) - } - } - } - } - } - }, - confirmButton = { - Button(onClick = onCreate, enabled = state.bewerbsTyp.isNotBlank()) { Text("Anlegen") } - }, - dismissButton = { - OutlinedButton(onClick = onDismiss) { Text("Abbrechen") } - }, - ) -} - -@Composable -private fun BewerbeDetailPanel(bewerb: BewerbUiModel?, modifier: Modifier = Modifier) { - var subTab by remember { mutableIntStateOf(0) } - val subTabs = listOf("Bewerb", "Bewertung", "Geldpreise", "Ort/Zeit") - - Column(modifier = modifier) { - PrimaryTabRow( - selectedTabIndex = subTab, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = PrimaryBlue, - ) { - subTabs.forEachIndexed { i, title -> - Tab( - selected = subTab == i, - onClick = { subTab = i }, - text = { Text(title, fontSize = 12.sp) }, - ) - } - } - HorizontalDivider() - - Box(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { - when (subTab) { - 0 -> BewerbSubTab(bewerb) - 1 -> BewertungSubTab(bewerb) - 2 -> GeldpreiseSubTab() - 3 -> OrtZeitSubTab(bewerb) - } - } - } -} - -// ── Sub-Tab: Bewerb ─────────────────────────────────────────────────────────── - -@Composable -private fun BewerbSubTab(bewerb: BewerbUiModel?) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - DetailField("Nummer:", bewerb?.nummer?.toString() ?: "") - DetailField("Abteilung:", "") - DetailField("Typ:", bewerb?.typ ?: "") - DetailField("Name:", bewerb?.name ?: "") - DetailField("Bezeichnung:", bewerb?.bezeichnung ?: "") - DetailDropdown("Kategorie:") - DetailDropdown("Klasse:") - DetailDropdown("Lizenz:") - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Maximal:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) - OutlinedTextField( - value = "3", - onValueChange = {}, - modifier = Modifier.width(60.dp), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), - ) - Spacer(Modifier.width(8.dp)) - Text("Pferde je Reiter", fontSize = 12.sp, color = Color(0xFF6B7280)) - } - DetailDropdown("Pferdealter:") - DetailField("Zeile 1:", bewerb?.zeile1 ?: "") - DetailField("Zeile 2:", "") - DetailField("Zeile 3:", "") - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Logo Bewerb:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) - OutlinedTextField( - value = "", - onValueChange = {}, - modifier = Modifier.weight(1f), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), - ) - Spacer(Modifier.width(4.dp)) - OutlinedButton( - onClick = {}, - modifier = Modifier.height(40.dp), - contentPadding = PaddingValues(horizontal = 8.dp) - ) { - Text("…", fontSize = 13.sp) - } - } - } -} - -// ── Sub-Tab: Bewertung ──────────────────────────────────────────────────────── - -@Composable -private fun BewertungSubTab(bewerb: BewerbUiModel?) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("Bewertungs-Konfiguration", fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = Color(0xFF374151)) - DetailField("Prüfung:", "Dressurreiterprüfung") - DetailField("Richtverfahren:", "A") - DetailField("Para-Grade:", "") - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Richteranzahl:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) - OutlinedTextField( - value = "2", - onValueChange = {}, - modifier = Modifier.width(60.dp), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), - ) - } - DetailField("Aufgabe:", "Aufgabe R") - DetailField("Aufgabennummer:", "") - DetailField("Maximalpunkte:", "") - HorizontalDivider() - Text("Richter", fontSize = 12.sp, color = Color(0xFF6B7280)) - RichterRow("C:", "Schuster Alexandra") - RichterRow("C:", "Vankova Kamila (CZ)") - } -} - -@Composable -private fun RichterRow(position: String, name: String) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(position, fontSize = 13.sp, modifier = Modifier.width(30.dp)) - OutlinedTextField( - value = name, - onValueChange = {}, - modifier = Modifier.weight(1f), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), - ) - Checkbox(checked = true, onCheckedChange = {}) - } -} - -// ── Sub-Tab: Geldpreise ─────────────────────────────────────────────────────── - -@Composable -private fun GeldpreiseSubTab() { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - // Geldpreis-Sektion - Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Geldpreis", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = false, onCheckedChange = {}) - Text("Geldpreis", fontSize = 13.sp) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Startgeld:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) - OutlinedTextField( - value = "15,00", - onValueChange = {}, - modifier = Modifier.width(100.dp), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Auszahlung:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) - DetailDropdown("fortführend", modifier = Modifier.weight(1f)) - } - } - } - // Geldpreis für Kadererreiter - Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Geldpreis für Kadererreiter", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = false, onCheckedChange = {}) - Text("Geldpreis für Kadererreiter", fontSize = 13.sp) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Startgeld für Kadererreiter:", fontSize = 13.sp, modifier = Modifier.width(180.dp)) - OutlinedTextField( - value = "15,00", - onValueChange = {}, - modifier = Modifier.width(100.dp), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), - ) - } - } - } - // Geldpreisvorlage - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Geldpreisvorlage wählen:", fontSize = 13.sp, modifier = Modifier.width(180.dp)) - DetailDropdown("", modifier = Modifier.weight(1f)) - } - // Tabelle - Text("0 Geldpreise", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) - Row( - modifier = Modifier.fillMaxWidth().background(HeaderBg).padding(horizontal = 8.dp, vertical = 6.dp), - ) { - Text("Nummer", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Geldpreis", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - } - HorizontalDivider() - Text("•", fontSize = 12.sp, color = Color(0xFF9CA3AF), modifier = Modifier.padding(8.dp)) - } -} - -// ── Sub-Tab: Ort/Zeit ───────────────────────────────────────────────────────── - -@Composable -private fun OrtZeitSubTab(bewerb: BewerbUiModel?) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Tag:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) - DetailDropdown(bewerb?.tag ?: "28.05.2023", modifier = Modifier.weight(1f)) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Beginnzeit:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) - DetailDropdown("fix um", modifier = Modifier.width(100.dp)) - } - LabeledTimeField("Beginnzeit:", bewerb?.beginn ?: "08:00", "(hh:mm)") - LabeledTimeField("Reitdauer:", "02:00", "(mm:ss)") - LabeledTimeField("Umbau:", "10", "(mm)") - LabeledTimeField("Besichtigung:", "10", "(mm)") - LabeledTimeField("Stechen:", "", "(mm)") - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Platz:", fontSize = 13.sp, modifier = Modifier.width(130.dp)) - DetailDropdown("Vorderer Turnierplatz", modifier = Modifier.weight(1f)) - } - } -} - -@Composable -private fun LabeledTimeField(label: String, value: String, unit: String) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(label, fontSize = 13.sp, modifier = Modifier.width(130.dp)) - OutlinedTextField( - value = value, - onValueChange = {}, - modifier = Modifier.width(80.dp), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), - ) - Spacer(Modifier.width(8.dp)) - Text(unit, fontSize = 12.sp, color = Color(0xFF6B7280)) - } -} - -// ── Hilfs-Composables ───────────────────────────────────────────────────────── - -@Composable -private fun DetailField(label: String, value: String) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(label, fontSize = 13.sp, modifier = Modifier.width(130.dp)) - OutlinedTextField( - value = value, - onValueChange = {}, - modifier = Modifier.weight(1f), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), - ) - } -} - -@Composable -private fun DetailDropdown(placeholder: String, modifier: Modifier = Modifier) { - OutlinedTextField( - value = placeholder, - onValueChange = {}, - modifier = modifier, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), - trailingIcon = { - Text("▼", fontSize = 10.sp, color = Color(0xFF6B7280)) - }, - ) -} - -// ── UI-Modell ───────────────────────────────────────────────────────────────── - -data class BewerbUiModel( - val tag: String, - val platz: Int, - val nummer: Int, - val beginn: String, - val ende: String, - val name: String, - val bezeichnung: String, - val typ: String, - val zeile1: String, - val zns: Int, - val nennungen: Int, - val warnungen: List = emptyList(), -) - -private fun sampleBewerbe() = listOf( - BewerbUiModel( - "28.05.2023", - 1, - 1, - "08:00", - "08:00", - "Dressurreiterprüfung Reiterpass\n(Aufgabe R 1)\nPony Einsteiger Cup OO", - "Dressurreiterprüfung Reiterpass", - "Dressur", - "Pony Einsteiger Cup OO", - 0, - 0, - emptyList() - ), - BewerbUiModel( - "28.05.2023", - 1, - 2, - "08:20", - "08:20", - "Dressurreiterprüfung Reitenadel\n(Aufgabe R 4)\nPony Einsteiger Cup OO", - "Dressurreiterprüfung Reitenadel", - "Dressur", - "Pony Einsteiger Cup OO", - 0, - 0, - emptyList() - ), - BewerbUiModel( - "28.05.2023", - 1, - 3, - "08:40", - "08:40", - "Dressurreiterprüfung lsf. (Istzfrei)\n(Aufgabe LF 1)", - "Dressurreiterprüfung lsf.", - "Dressur", - "", - 0, - 0 - ), - BewerbUiModel( - "28.05.2023", - 1, - 4, - "09:00", - "09:00", - "Dressurreiterprüfung lsf. (Lizenzfrei)\n(Aufgabe LF 3)", - "Dressurreiterprüfung lsf.", - "Dressur", - "", - 0, - 0 - ), - BewerbUiModel( - "28.05.2023", - 1, - 5, - "09:20", - "09:20", - "Führzügelklasse\nOO Kids Cup", - "Führzügelklasse", - "Dressur", - "OO Kids Cup", - 0, - 0 - ), - BewerbUiModel( - "28.05.2023", - 1, - 6, - "09:40", - "09:40", - "First Ridden\nOO Kids Cup", - "First Ridden", - "Dressur", - "OO Kids Cup", - 0, - 0 - ), - BewerbUiModel( - "28.05.2023", - 1, - 7, - "10:00", - "10:00", - "Pony Dressurprüfung Kl. A (Aufgabe P 1)", - "Pony Dressurprüfung Kl. A", - "Dressur", - "", - 0, - 0 - ), - BewerbUiModel( - "28.05.2023", - 1, - 8, - "10:20", - "10:20", - "Dressurreiterprüfung Kl. A (Aufgabe DRA 1)", - "Dressurreiterprüfung Kl. A", - "Dressur", - "", - 0, - 0 - ), - BewerbUiModel( - "28.05.2023", - 1, - 9, - "10:40", - "10:40", - "Dressurreiterprüfung Kl. A (Aufgabe A 5)", - "Dressurreiterprüfung Kl. A", - "Dressur", - "", - 0, - 0 - ), - BewerbUiModel( - "28.05.2023", - 1, - 10, - "11:00", - "11:00", - "Pony Dressurprüfung Kl. A (Aufgabe P 9)", - "Pony Dressurprüfung Kl. A", - "Dressur", - "", - 0, - 0 - ), - BewerbUiModel( - "28.05.2023", - 1, - 11, - "11:20", - "11:20", - "Dressurreiterprüfung Kl. L (Aufgabe DRL 1)", - "Dressurreiterprüfung Kl. L", - "Dressur", - "", - 0, - 0 - ), - BewerbUiModel( - "28.05.2023", - 1, - 12, - "11:40", - "11:40", - "Dressurprüfung Kl. L (Aufgabe L 3)", - "Dressurprüfung Kl. L", - "Dressur", - "", - 0, - 0 - ), -) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt deleted file mode 100644 index 47814052..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt +++ /dev/null @@ -1,137 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.koin.compose.koinInject -import org.koin.core.parameter.parametersOf - -/** - * Detailansicht eines Turniers gemäß Vision_03. - * - * Layout: Horizontale Tab-Bar mit 8 Tabs (kein eigener Toolbar-Zurück-Button – - * Navigation erfolgt über den Breadcrumb in der TopBar). - * - * Tabs: - * 1. STAMMDATEN – Turnier-Konfiguration, ZNS-Import, Sparten, Datum - * 2. ORGANISATION – Funktionäre, Richterkollegium, Austragungsplätze - * 3. BEWERBE – 3-spaltiges Layout (Aktionen | Tabelle | Detail-Panel) - * 4. ARTIKEL – Gebühren, Stallungen & Boxen, Zusatzgebühren - * 5. ABRECHNUNG – Buchungen, Offene Posten, Rechnung - * 6. NENNUNGEN – Pferd+Reiter-Suche, Verkauf/Buchungen, Bewerbsübersicht - * 7. STARTLISTEN – Bewerbs-Tabs, Sortierung, Zeit/Dauer - * 8. ERGEBNISLISTEN – Bewerbs-Tabs, Platzierung & Geldpreise - * - */ -@Composable -fun TurnierDetailScreen( - veranstaltungId: Long, - turnierId: Long, - onBack: () -> Unit, - eventVon: String? = null, - eventBis: String? = null, - eventOrt: String? = null, - veranstalterName: String? = null, - veranstalterOrt: String? = null, - veranstalterBundesland: String? = null, - veranstalterLogoUrl: String? = null, -) { - var selectedTab by remember { mutableIntStateOf(0) } - - // Temporäre Lösung bis zur echten Repository-Anbindung: - // Da TurnierDetailScreen in einem anderen Modul liegt, übergeben wir - // die Veranstaltungsinformationen eigentlich via ViewModel. - // Hier nutzen wir vorerst koin oder Parameter. - - val tabs = listOf( - "STAMMDATEN", - "ORGANISATION", - "BEWERBE", - "ARTIKEL", - "ABRECHNUNG", - "NENNUNGEN", - "ONLINE-EINGANG", - "ZEITPLAN", - "STARTLISTEN", - "ERGEBNISLISTEN", - ) - - val bewerbViewModel: BewerbViewModel = koinInject { parametersOf(turnierId) } - - Column(modifier = Modifier.fillMaxSize()) { - // Horizontale Tab-Bar (direkt unter der TopBar) - PrimaryScrollableTabRow( - selectedTabIndex = selectedTab, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = Color(0xFF1E3A8A), - edgePadding = 0.dp, - ) { - tabs.forEachIndexed { index, title -> - Tab( - selected = selectedTab == index, - onClick = { selectedTab = index }, - text = { - Text( - text = title, - fontSize = 13.sp, - fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal, - ) - }, - ) - } - } - - HorizontalDivider() - - // Tab-Inhalte - Box(modifier = Modifier.fillMaxSize()) { - when (selectedTab) { - 0 -> StammdatenTabContent( - turnierId = turnierId, - eventVon = eventVon, - eventBis = eventBis, - eventOrt = eventOrt, - veranstalterName = veranstalterName, - veranstalterOrt = veranstalterOrt, - veranstalterBundesland = veranstalterBundesland, - veranstalterLogoUrl = veranstalterLogoUrl, - ) - 1 -> { - val nennungViewModel = koinInject(parameters = { parametersOf(turnierId) }) - OrganisationTabContent(viewModel = nennungViewModel) - } - 2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId) - 3 -> ArtikelTabContent() - 4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId) - 5 -> { - val nennungViewModel = koinInject(parameters = { parametersOf(turnierId) }) - NennungenTabContent( - viewModel = nennungViewModel, - onAbrechnungClick = { selectedTab = 4 } - ) - } - 6 -> { - val nennungViewModel = koinInject(parameters = { parametersOf(turnierId) }) - OnlineNennungEingangTabContent(turnierNr = turnierId.toString(), viewModel = nennungViewModel) - } - 7 -> ZeitplanTabContent(turnierId = turnierId, viewModel = bewerbViewModel) - 8 -> StartlistenTabContent() - 9 -> ErgebnislistenTabContent() - } - } - } -} - -// Tab-Inhalte werden in dedizierten Dateien implementiert: -// TurnierBewerbeTab.kt → BewerbeTabContent() -// TurnierNennungenTab.kt → NennungenTabContent() -// TurnierStartlistenTab.kt → StartlistenTabContent() -// TurnierZeitplanTab.kt → ZeitplanTabContent() -// TurnierErgebnislistenTab.kt → ErgebnislistenTabContent() diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt deleted file mode 100644 index ac0a09c3..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt +++ /dev/null @@ -1,269 +0,0 @@ -package at.mocode.turnier.feature.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.turnier.feature.domain.Ergebnis -import at.mocode.turnier.feature.domain.model.StartlistenZeile -import org.koin.compose.koinInject - -private val ElBlue = Color(0xFF1E3A8A) -private val ElHeaderBg = Color(0xFFF1F5F9) - -/** - * ERGEBNISLISTEN-Tab gemäß Vision_03. - * - * Layout: 2-spaltig - * - Links (flex): Bewerbs-Tabs + Ergebnis-Tabelle (Platz | Startnr | Pferd | Reiter | Fehler | Zeit | Punkte) - * - Rechts (280dp): Platzierung & Geldpreis-Panel - */ -@Composable -fun ErgebnislistenTabContent( - viewModel: BewerbViewModel = koinInject() -) { - val state by viewModel.state.collectAsState() - - Row(modifier = Modifier.fillMaxSize()) { - // ── Linke Spalte: Bewerbs-Tabs + Tabelle ───────────────────────────── - Column(modifier = Modifier.weight(1f).fillMaxHeight()) { - ErgebnislistenBewerbsTabs( - bewerbe = state.list, - selectedId = state.selectedId, - onSelect = { viewModel.send(BewerbIntent.Select(it)) }, - ergebnisse = state.ergebnisse, - startliste = state.currentStartliste, - onCalculate = { viewModel.send(BewerbIntent.CalculatePlatzierung) }, - onPrint = { viewModel.send(BewerbIntent.ExportErgebnislistePdf) } - ) - } - - VerticalDivider() - - // ── Rechte Spalte: Platzierung & Geldpreis ─────────────────────────── - PlatzierungGeldpreisPanel( - modifier = Modifier.width(280.dp).fillMaxHeight(), - ergebnisse = state.ergebnisse - ) - } -} - -@Composable -private fun ErgebnislistenBewerbsTabs( - bewerbe: List, - selectedId: Long?, - onSelect: (Long?) -> Unit, - ergebnisse: List, - startliste: List, - onCalculate: () -> Unit, - onPrint: () -> Unit -) { - val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0) - - PrimaryScrollableTabRow( - selectedTabIndex = selectedIndex, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = ElBlue, - edgePadding = 0.dp, - ) { - bewerbe.forEachIndexed { index, bewerb -> - Tab( - selected = selectedId == bewerb.id, - onClick = { onSelect(bewerb.id) }, - text = { Text(bewerb.tag, fontSize = 12.sp) }, - ) - } - } - HorizontalDivider() - - val selectedBewerb = bewerbe.getOrNull(selectedIndex) - - // Toolbar - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = selectedBewerb?.let { "${it.tag} – ${it.name}" } ?: "Kein Bewerb ausgewählt", - fontWeight = FontWeight.SemiBold, - fontSize = 13.sp, - ) - Spacer(Modifier.weight(1f)) - OutlinedButton( - onClick = onCalculate, - modifier = Modifier.height(32.dp), - contentPadding = PaddingValues(horizontal = 10.dp) - ) { - Text("Platzierung berechnen", fontSize = 12.sp) - } - OutlinedButton( - onClick = {}, - modifier = Modifier.height(32.dp), - contentPadding = PaddingValues(horizontal = 10.dp) - ) { - Text("Exportieren", fontSize = 12.sp) - } - OutlinedButton( - onClick = onPrint, - modifier = Modifier.height(32.dp), - contentPadding = PaddingValues(horizontal = 10.dp) - ) { - Text("Drucken", fontSize = 12.sp) - } - } - - // Tabellen-Header - Row( - modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp)) - Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(65.dp)) - Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Fehler", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp)) - Text("Zeit", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp)) - Text("Punkte", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp)) - } - HorizontalDivider() - - if (ergebnisse.isEmpty()) { - // Leere Liste - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Keine Ergebnisse vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280)) - Spacer(Modifier.height(8.dp)) - Text("Ergebnisse werden nach dem Turnier eingetragen.", fontSize = 12.sp, color = Color(0xFF9CA3AF)) - Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = {}) { - Text("Ergebnisse importieren", fontSize = 13.sp) - } - Button( - onClick = {}, - colors = ButtonDefaults.buttonColors(containerColor = ElBlue), - ) { - Text("Ergebnisse eingeben", fontSize = 13.sp) - } - } - } - } - } else { - androidx.compose.foundation.lazy.LazyColumn(modifier = Modifier.fillMaxSize()) { - items(ergebnisse.size) { index -> - val erg = ergebnisse[index] - val zeile = startliste.find { it.nennungId == erg.nennungId } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(erg.platzierung?.let { "$it." } ?: "-", fontSize = 12.sp, modifier = Modifier.width(50.dp), fontWeight = FontWeight.Bold, color = ElBlue) - Text(zeile?.nr?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(65.dp)) - Text(zeile?.pferd ?: "Unbekannt", fontSize = 12.sp, modifier = Modifier.weight(1f)) - Text(zeile?.reiter ?: "Unbekannt", fontSize = 12.sp, modifier = Modifier.weight(1f)) - Text(erg.fehler?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(60.dp)) - Text(erg.zeit?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(70.dp)) - Text(erg.wertnote?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(70.dp)) - } - HorizontalDivider(color = Color(0xFFE5E7EB)) - } - } - } -} - -@Composable -private fun PlatzierungGeldpreisPanel( - modifier: Modifier = Modifier, - ergebnisse: List = emptyList() -) { - val platzierteCount = ergebnisse.count { it.platzierung != null } - Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Platzierung & Geldpreis", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) - HorizontalDivider() - - // Anzahl Platzierte - Text("Platzierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Anzahl Platzierte:", fontSize = 12.sp, modifier = Modifier.width(140.dp)) - OutlinedTextField( - value = platzierteCount.toString(), - onValueChange = {}, - modifier = Modifier.width(60.dp), - readOnly = true, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 12.sp), - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Stechen ab Platz:", fontSize = 12.sp, modifier = Modifier.width(140.dp)) - OutlinedTextField( - value = "", - onValueChange = {}, - modifier = Modifier.width(60.dp), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 12.sp), - ) - } - - HorizontalDivider() - - // Geldpreise - Text("Geldpreise", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium) - Row( - modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp)) - Text("Betrag (€)", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - } - HorizontalDivider() - listOf(1 to "–", 2 to "–", 3 to "–").forEach { (platz, betrag) -> - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "$platz.", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = ElBlue, - modifier = Modifier.width(50.dp) - ) - OutlinedTextField( - value = betrag, - onValueChange = {}, - modifier = Modifier.weight(1f).height(36.dp), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 12.sp), - ) - } - HorizontalDivider(color = Color(0xFFE5E7EB)) - } - - HorizontalDivider() - - // Aktions-Buttons - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Button( - onClick = {}, - colors = ButtonDefaults.buttonColors(containerColor = ElBlue), - modifier = Modifier.fillMaxWidth(), - ) { - Text("Platzierung berechnen", fontSize = 12.sp) - } - OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { - Text("Ergebnisliste drucken", fontSize = 12.sp) - } - OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { - Text("Geldpreise auszahlen", fontSize = 12.sp) - } - } - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNennungViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNennungViewModel.kt deleted file mode 100644 index b8c41f48..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNennungViewModel.kt +++ /dev/null @@ -1,163 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import at.mocode.turnier.feature.data.remote.dto.NennungEinreichenRequest -import at.mocode.turnier.feature.domain.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -// --- Mock-Modelle für Online-Nennungen innerhalb dieses Moduls --- -data class OnlineNennung( - val id: String, - val vorname: String, - val nachname: String, - val lizenz: String, - val pferdName: String, - val pferdAlter: String, - val email: String, - val bewerbe: String -) - -data class TurnierOnlineUiState( - val onlineNennungen: List = emptyList(), - val isOnlineLoading: Boolean = false -) - -data class NennungenState( - val isLoading: Boolean = false, - val nennungen: List = emptyList(), - val searchResultsReiter: List = emptyList(), - val searchResultsPferde: List = emptyList(), - val searchResultsFunktionaere: List = emptyList(), - val selectedReiter: Reiter? = null, - val selectedPferd: Pferd? = null, - val errorMessage: String? = null -) - -class TurnierNennungViewModel( - private val nennungRepo: NennungRepository, - private val masterdataRepo: MasterdataRepository, - private val turnierId: Long -) { - // UI-State für den Online-Eingang Tab - val uiState = MutableStateFlow(TurnierOnlineUiState()) - - fun loadOnlineNennungen() { - uiState.value = uiState.value.copy(isOnlineLoading = true) - scope.launch { - // Mock-Laden - kotlinx.coroutines.delay(500) - uiState.value = uiState.value.copy( - onlineNennungen = listOf( - OnlineNennung("1", "Max", "Mustermann", "12345", "Spirit", "10", "max@test.at", "1, 2, 5") - ), - isOnlineLoading = false - ) - } - } - - fun uebernehmeOnlineNennung(nennung: OnlineNennung) { - // Logik zur Übernahme - } - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - private val _state = MutableStateFlow(NennungenState()) - val state: StateFlow = _state - - init { - loadNennungen() - } - - fun loadNennungen() { - _state.value = _state.value.copy(isLoading = true) - scope.launch { - nennungRepo.list(turnierId).onSuccess { list -> - _state.value = _state.value.copy(nennungen = list, isLoading = false) - }.onFailure { - _state.value = _state.value.copy(errorMessage = "Fehler beim Laden: ${it.message}", isLoading = false) - } - } - } - - fun searchReiter(query: String) { - if (query.length < 2) { - _state.value = _state.value.copy(searchResultsReiter = emptyList()) - return - } - scope.launch { - masterdataRepo.searchReiter(query).onSuccess { list -> - _state.value = _state.value.copy(searchResultsReiter = list) - } - } - } - - fun selectReiter(reiter: Reiter?) { - _state.value = _state.value.copy(selectedReiter = reiter) - } - - fun saveReiter(reiter: Reiter) { - scope.launch { - masterdataRepo.saveReiter(reiter).onSuccess { - _state.value = _state.value.copy(selectedReiter = null) - } - } - } - - fun searchPferde(query: String) { - if (query.length < 2) { - _state.value = _state.value.copy(searchResultsPferde = emptyList()) - return - } - scope.launch { - masterdataRepo.searchPferde(query).onSuccess { list -> - _state.value = _state.value.copy(searchResultsPferde = list) - } - } - } - - fun selectPferd(pferd: Pferd?) { - _state.value = _state.value.copy(selectedPferd = pferd) - } - - fun savePferd(pferd: Pferd) { - scope.launch { - masterdataRepo.savePferd(pferd).onSuccess { - _state.value = _state.value.copy(selectedPferd = null) - } - } - } - - fun searchFunktionaere(query: String) { - if (query.length < 2) { - _state.value = _state.value.copy(searchResultsFunktionaere = emptyList()) - return - } - scope.launch { - masterdataRepo.searchFunktionaere(query).onSuccess { list -> - _state.value = _state.value.copy(searchResultsFunktionaere = list) - } - } - } - - fun einreichen(bewerbId: String, abteilungId: String, reiterId: String, pferdId: String) { - _state.value = _state.value.copy(isLoading = true) - scope.launch { - val request = NennungEinreichenRequest( - abteilungId = abteilungId, - bewerbId = bewerbId, - turnierId = turnierId.toString(), - reiterId = reiterId, - pferdId = pferdId - ) - nennungRepo.einreichen(request).onSuccess { - loadNennungen() - }.onFailure { - _state.value = _state.value.copy(errorMessage = "Fehler beim Einreichen: ${it.message}", isLoading = false) - } - } - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNennungenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNennungenTab.kt deleted file mode 100644 index 187ebf9d..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNennungenTab.kt +++ /dev/null @@ -1,289 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -private val NennBlue = Color(0xFF1E3A8A) -private val NennHeaderBg = Color(0xFFF1F5F9) -private val NennSelectedBg = Color(0xFFEFF6FF) - -/** - * NENNUNGEN-Tab gemäß Vision_03. - * - * Layout: 2-spaltig - * - Links (flex): Pferd+Reiter-Suche + Nennungs-Tabelle - * - Rechts (360dp): Verkauf/Buchungen + Bewerbsübersicht - */ -@Composable -fun NennungenTabContent( - viewModel: TurnierNennungViewModel, - onAbrechnungClick: () -> Unit = {} -) { - val state by viewModel.state.collectAsState() - - // --- Editoren --- - state.selectedReiter?.let { reiter -> - ReiterEditDialog( - reiter = reiter, - onDismiss = { viewModel.selectReiter(null) }, - onSave = { viewModel.saveReiter(it) } - ) - } - state.selectedPferd?.let { pferd -> - PferdEditDialog( - pferd = pferd, - onDismiss = { viewModel.selectPferd(null) }, - onSave = { viewModel.savePferd(it) } - ) - } - - Row(modifier = Modifier.fillMaxSize()) { - // ── Linke Spalte: Suche + Tabelle ───────────────────────────────────── - Column(modifier = Modifier.weight(1f).fillMaxHeight()) { - NennungenSuchePanel(viewModel) - HorizontalDivider() - NennungenTabelle(viewModel, state) - } - - VerticalDivider() - - // ── Rechte Spalte: Verkauf + Bewerbsübersicht ───────────────────────── - Column( - modifier = Modifier - .width(360.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - ) { - VerkaufBuchungenPanel(onAbrechnungClick) - HorizontalDivider() - BewerbsuebersichtPanel() - } - } -} - -@Composable -private fun NennungenSuchePanel(viewModel: TurnierNennungViewModel) { - var pferdQuery by remember { mutableStateOf("") } - var reiterQuery by remember { mutableStateOf("") } - - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Pferd & Reiter suchen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = pferdQuery, - onValueChange = { - pferdQuery = it - viewModel.searchPferde(it) - }, - placeholder = { Text("Pferd suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) }, - modifier = Modifier.weight(1f).height(44.dp), - singleLine = true, - ) - OutlinedTextField( - value = reiterQuery, - onValueChange = { - reiterQuery = it - viewModel.searchReiter(it) - }, - placeholder = { Text("Reiter suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) }, - modifier = Modifier.weight(1f).height(44.dp), - singleLine = true, - ) - Button( - onClick = { /* In einem echten Dialog würde hier die Auswahl kombiniert */ }, - colors = ButtonDefaults.buttonColors(containerColor = NennBlue), - modifier = Modifier.height(44.dp), - ) { - Text("Nennen", fontSize = 12.sp) - } - } - } -} - -@Composable -private fun NennungenTabelle(viewModel: TurnierNennungViewModel, state: NennungenState) { - var selectedIndex by remember { mutableIntStateOf(-1) } - - Column(modifier = Modifier.fillMaxSize()) { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .background(NennHeaderBg) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text("ID", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp)) - Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Status", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp)) - } - HorizontalDivider() - - if (state.isLoading) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - - if (state.nennungen.isEmpty() && !state.isLoading) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Keine Nennungen vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280)) - Spacer(Modifier.height(8.dp)) - Text( - "Suchen Sie nach Pferd und Reiter, um eine EntryManagement hinzuzufügen.", - fontSize = 12.sp, - color = Color(0xFF9CA3AF) - ) - } - } - } else { - LazyColumn(modifier = Modifier.fillMaxSize()) { - itemsIndexed(state.nennungen) { index, nennung -> - Row( - modifier = Modifier - .fillMaxWidth() - .background(if (index == selectedIndex) NennSelectedBg else Color.Transparent) - .clickable { selectedIndex = index } - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - nennung.id.takeLast(6), - fontSize = 12.sp, - modifier = Modifier.width(60.dp), - color = NennBlue, - fontWeight = FontWeight.Bold - ) - Text(nennung.pferdId.takeLast(8), fontSize = 12.sp, modifier = Modifier.weight(1f)) - Text(nennung.reiterId.takeLast(8), fontSize = 12.sp, modifier = Modifier.weight(1f)) - NennungStatusBadge(nennung.status) - } - HorizontalDivider(color = Color(0xFFE5E7EB)) - } - } - } - } -} - -@Composable -private fun NennungStatusBadge(status: String) { - val (bg, fg) = when (status) { - "Gemeldet" -> Color(0xFFDCFCE7) to Color(0xFF16A34A) - "Bezahlt" -> Color(0xFFDBEAFE) to NennBlue - "Abgemeldet" -> Color(0xFFFEE2E2) to Color(0xFFDC2626) - else -> Color(0xFFF3F4F6) to Color(0xFF6B7280) - } - Surface(shape = MaterialTheme.shapes.small, color = bg) { - Text( - text = status, - fontSize = 10.sp, - color = fg, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) - } -} - -@Composable -private fun VerkaufBuchungenPanel(onAbrechnungClick: () -> Unit = {}) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Verkauf / Buchungen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) - TextButton(onClick = onAbrechnungClick) { - Text("Zur Abrechnung", fontSize = 11.sp, color = NennBlue) - } - } - - // Artikel-Buchungen - Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Artikel-Buchungen", fontSize = 12.sp, fontWeight = FontWeight.Medium, color = Color(0xFF374151)) - Row( - modifier = Modifier.fillMaxWidth().background(NennHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text("Artikel", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Menge", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp)) - Text("Preis", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp)) - } - HorizontalDivider() - Text( - "Keine Buchungen", - fontSize = 12.sp, - color = Color(0xFF9CA3AF), - modifier = Modifier.padding(vertical = 8.dp) - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - OutlinedButton( - onClick = {}, - modifier = Modifier.height(32.dp), - contentPadding = PaddingValues(horizontal = 10.dp) - ) { - Text("+ Artikel buchen", fontSize = 11.sp) - } - } - } - } - } -} - -@Composable -private fun BewerbsuebersichtPanel() { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Bewerbsübersicht", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) - Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row( - modifier = Modifier.fillMaxWidth().background(NennHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text("Bewerb", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Nennungen", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp)) - } - HorizontalDivider() - listOf( - "Bewerb 1 – Dressur Kl. A" to 0, - "Bewerb 2 – Dressur Kl. L" to 0, - "Bewerb 3 – Springen Kl. A" to 0, - ).forEach { (name, count) -> - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(name, fontSize = 12.sp, modifier = Modifier.weight(1f)) - Text("$count", fontSize = 12.sp, modifier = Modifier.width(80.dp), color = Color(0xFF6B7280)) - } - HorizontalDivider(color = Color(0xFFE5E7EB)) - } - } - } - } -} - -// ── UI-Modell ───────────────────────────────────────────────────────────────── - -private data class NennungUiModel( - val startnr: Int, - val pferd: String, - val reiter: String, - val bewerb: String, - val status: String, -) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNeuScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNeuScreen.kt deleted file mode 100644 index cfef50b5..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierNeuScreen.kt +++ /dev/null @@ -1,65 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import at.mocode.frontend.core.designsystem.models.PlaceholderContent - -/** - * Formular zum Anlegen eines neuen Turniers (Vision_03: /veranstaltung/{id}/turnier/neu). - * Tabs: Übersicht | Stammdaten (A-Satz) | Organisation | Bewerbe ⭐ | Preisliste - * TODO: Echte Formular-Felder und Persistenz (Phase 4/5). - */ -@Composable -fun TurnierNeuScreen( - veranstaltungId: Long, - onBack: () -> Unit, - onSave: () -> Unit, -) { - var selectedTab by remember { mutableIntStateOf(3) } // Bewerbe ist Standard-Tab (⭐) - val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Bewerbe ⭐", "Preisliste") - - Column(modifier = Modifier.fillMaxSize()) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row { - 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, - modifier = Modifier.alignByBaseline(), - ) - } - Button(onClick = onSave) { Text("Speichern") } - } - - PrimaryTabRow(selectedTabIndex = selectedTab) { - tabs.forEachIndexed { index, title -> - Tab( - selected = selectedTab == index, - onClick = { selectedTab = index }, - text = { Text(title) }, - ) - } - } - - Box(modifier = Modifier.fillMaxSize().padding(24.dp)) { - when (selectedTab) { - 0 -> PlaceholderContent("Übersicht", "Wird nach dem Speichern befüllt.") - 1 -> PlaceholderContent("Stammdaten (A-Satz)", "OEPS-Turniernummer, Kategorie, Sparte …") - 2 -> PlaceholderContent("Organisation", "Richter, Parcourschef, Tierarzt …") - 3 -> PlaceholderContent("Bewerbe", "Bewerbe anlegen und Abteilungen konfigurieren …") - 4 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …") - } - } - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierOnlineNennungenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierOnlineNennungenTab.kt deleted file mode 100644 index e62fcbd1..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierOnlineNennungenTab.kt +++ /dev/null @@ -1,108 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -@Composable -fun OnlineNennungEingangTabContent(turnierNr: String, viewModel: TurnierNennungViewModel) { - val uiState by viewModel.uiState.collectAsState() - - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text("Eingegangene Online-Nennungen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - Text("Turnier: $turnierNr", style = MaterialTheme.typography.bodyMedium, color = Color.Gray) - } - - Button( - onClick = { viewModel.loadOnlineNennungen() }, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)), - enabled = !uiState.isOnlineLoading - ) { - if (uiState.isOnlineLoading) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), color = Color.White, strokeWidth = 2.dp) - } else { - Icon(Icons.Default.Refresh, contentDescription = null) - } - Spacer(Modifier.width(8.dp)) - Text("Aktualisieren") - } - } - - if (uiState.onlineNennungen.isEmpty() && !uiState.isOnlineLoading) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Keine neuen Nennungen vorhanden.", color = Color.Gray) - } - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { - items(uiState.onlineNennungen) { nennung -> - NennungEingangCard(nennung, onUebernehmen = { viewModel.uebernehmeOnlineNennung(nennung) }) - } - } - } - } -} - -@Composable -fun NennungEingangCard(nennung: OnlineNennung, onUebernehmen: () -> Unit) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = Color.White), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("${nennung.vorname} ${nennung.nachname}", fontWeight = FontWeight.Bold, fontSize = 16.sp) - Spacer(Modifier.width(8.dp)) - Badge(containerColor = Color(0xFFE3F2FD)) { Text(nennung.lizenz, color = Color(0xFF1976D2)) } - } - Spacer(Modifier.height(4.dp)) - Text("Pferd: ${nennung.pferdName} (*${nennung.pferdAlter})", style = MaterialTheme.typography.bodyMedium) - Text("Bewerbe: ${nennung.bewerbe}", style = MaterialTheme.typography.bodySmall, color = Color(0xFF2E7D32), fontWeight = FontWeight.Bold) - } - - Column(horizontalAlignment = Alignment.End) { - Text(nennung.email, style = MaterialTheme.typography.labelSmall, color = Color.Gray) - Spacer(Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { /* Details */ }, shape = RoundedCornerShape(8.dp)) { - Text("Details") - } - Button( - onClick = onUebernehmen, - shape = RoundedCornerShape(8.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32)) - ) { - Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(4.dp)) - Text("Übernehmen") - } - } - } - } - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierOrganisationTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierOrganisationTab.kt deleted file mode 100644 index 3cac7f38..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierOrganisationTab.kt +++ /dev/null @@ -1,523 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -private val PrimaryBlue = Color(0xFF1E3A8A) -private val AccentBlue = Color(0xFF3B82F6) -private val DeleteRed = Color(0xFFDC2626) - -/** - * ORGANISATION-Tab im TurnierDetailScreen. - * Gemäß Figma Vision_03 (figma-entwurf_13 / figma-entwurf_14): - * - Funktionäre & Offizielle (C-Satz): Turnierleiter, Turnierbeauftragter, Technischer Delegierter, Parcourschef - * - Support-Team: Tierarzt, Schmied, Steward - * - Richterkollegium: dynamische Liste (Name, Qualifikation, Funktion, Löschen) - * - Austragungsplätze: dynamische Liste (Sparte, Größe, Bezeichnung, Löschen) - */ -@Composable -fun OrganisationTabContent(viewModel: TurnierNennungViewModel) { - val state by viewModel.state.collectAsState() - - var turnierleiter by remember { mutableStateOf("") } - var turnierbeauftragter by remember { mutableStateOf("") } - var technischerDelegierter by remember { mutableStateOf("") } - var parcourschef by remember { mutableStateOf("") } - var tierarzt by remember { mutableStateOf("") } - var schmied by remember { mutableStateOf("") } - var steward by remember { mutableStateOf("") } - - // --- Dropdown-States für die Suche --- - var showTLDropdown by remember { mutableStateOf(false) } - var showTBDropdown by remember { mutableStateOf(false) } - var showTDDropdown by remember { mutableStateOf(false) } - var showPCDropdown by remember { mutableStateOf(false) } - var showTADropdown by remember { mutableStateOf(false) } - var showSMDropdown by remember { mutableStateOf(false) } - var showSTDropdown by remember { mutableStateOf(false) } - - var richter by remember { - mutableStateOf( - listOf( - RichterUiModel("Alexandra Schuster", "D-GP", "Hauptrichter"), - RichterUiModel("Ulrike Knasmüller-Prinz", "D-M", "Beisitzer"), - ), - ) - } - - var plaetze by remember { - mutableStateOf( - listOf( - AustragungsplatzUiModel("Dressur", "20 x 60 m", "Hauptplatz"), - AustragungsplatzUiModel("Dressur", "20 x 40 m", "Abreiteplatz 1"), - ), - ) - } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // ── Funktionäre & Offizielle ───────────────────────────────────────── - OrgSectionCard(title = "Funktionäre & Offizielle (C-Satz)") { - OrgSubSection("Turnier-Organisation") { - Box { - OrgSearchField("Turnierleiter:", turnierleiter) { - turnierleiter = it - viewModel.searchFunktionaere(it) - showTLDropdown = it.length >= 2 - } - DropdownMenu( - expanded = showTLDropdown && state.searchResultsFunktionaere.isNotEmpty(), - onDismissRequest = { showTLDropdown = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false) - ) { - state.searchResultsFunktionaere.forEach { f -> - DropdownMenuItem( - text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, - onClick = { - turnierleiter = f.name - showTLDropdown = false - } - ) - } - } - } - Box { - OrgSearchField("Turnierbeauftragter:", turnierbeauftragter) { - turnierbeauftragter = it - viewModel.searchFunktionaere(it) - showTBDropdown = it.length >= 2 - } - DropdownMenu( - expanded = showTBDropdown && state.searchResultsFunktionaere.isNotEmpty(), - onDismissRequest = { showTBDropdown = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false) - ) { - state.searchResultsFunktionaere.forEach { f -> - DropdownMenuItem( - text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, - onClick = { - turnierbeauftragter = f.name - showTBDropdown = false - } - ) - } - } - } - Box { - OrgSearchField("Technischer Delegierter:", technischerDelegierter) { - technischerDelegierter = it - viewModel.searchFunktionaere(it) - showTDDropdown = it.length >= 2 - } - DropdownMenu( - expanded = showTDDropdown && state.searchResultsFunktionaere.isNotEmpty(), - onDismissRequest = { showTDDropdown = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false) - ) { - state.searchResultsFunktionaere.forEach { f -> - DropdownMenuItem( - text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, - onClick = { - technischerDelegierter = f.name - showTDDropdown = false - } - ) - } - } - } - Box { - OrgSearchField("Parcourschef:", parcourschef) { - parcourschef = it - viewModel.searchFunktionaere(it) - showPCDropdown = it.length >= 2 - } - DropdownMenu( - expanded = showPCDropdown && state.searchResultsFunktionaere.isNotEmpty(), - onDismissRequest = { showPCDropdown = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false) - ) { - state.searchResultsFunktionaere.forEach { f -> - DropdownMenuItem( - text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, - onClick = { - parcourschef = f.name - showPCDropdown = false - } - ) - } - } - } - } - OrgSubSection("Support-Team") { - Box { - OrgSearchField("Tierarzt:", tierarzt) { - tierarzt = it - viewModel.searchFunktionaere(it) - showTADropdown = it.length >= 2 - } - DropdownMenu( - expanded = showTADropdown && state.searchResultsFunktionaere.isNotEmpty(), - onDismissRequest = { showTADropdown = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false) - ) { - state.searchResultsFunktionaere.forEach { f -> - DropdownMenuItem( - text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, - onClick = { - tierarzt = f.name - showTADropdown = false - } - ) - } - } - } - Box { - OrgSearchField("Schmied:", schmied) { - schmied = it - viewModel.searchFunktionaere(it) - showSMDropdown = it.length >= 2 - } - DropdownMenu( - expanded = showSMDropdown && state.searchResultsFunktionaere.isNotEmpty(), - onDismissRequest = { showSMDropdown = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false) - ) { - state.searchResultsFunktionaere.forEach { f -> - DropdownMenuItem( - text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, - onClick = { - schmied = f.name - showSMDropdown = false - } - ) - } - } - } - Box { - OrgSearchField("Steward:", steward) { - steward = it - viewModel.searchFunktionaere(it) - showSTDropdown = it.length >= 2 - } - DropdownMenu( - expanded = showSTDropdown && state.searchResultsFunktionaere.isNotEmpty(), - onDismissRequest = { showSTDropdown = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false) - ) { - state.searchResultsFunktionaere.forEach { f -> - DropdownMenuItem( - text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, - onClick = { - steward = f.name - showSTDropdown = false - } - ) - } - } - } - } - } - - // ── Richterkollegium ───────────────────────────────────────────────── - OrgSectionCard( - title = "Richterkollegium", - action = { - TextButton(onClick = { - richter = richter + RichterUiModel("", "", "") - }) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue) - Spacer(Modifier.width(4.dp)) - Text("Richter hinzufügen", color = AccentBlue, fontSize = 13.sp) - } - }, - ) { - // Tabellen-Header - Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) { - Text("Name", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f)) - Text("Qualifikation", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f)) - Text("Funktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f)) - Text("Aktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(48.dp)) - } - HorizontalDivider() - richter.forEachIndexed { idx, r -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Name-Suche mit Dropdown - var showRichterDropdown by remember { mutableStateOf(false) } - Box(modifier = Modifier.weight(3f).padding(end = 8.dp)) { - OutlinedTextField( - value = r.name, - onValueChange = { v -> - richter = richter.toMutableList().also { it[idx] = r.copy(name = v) } - viewModel.searchFunktionaere(v) - showRichterDropdown = v.length >= 2 - }, - modifier = Modifier.fillMaxWidth().height(44.dp), - singleLine = true, - placeholder = { Text("Name suchen...", fontSize = 12.sp) } - ) - DropdownMenu( - expanded = showRichterDropdown && state.searchResultsFunktionaere.isNotEmpty(), - onDismissRequest = { showRichterDropdown = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false) - ) { - state.searchResultsFunktionaere.forEach { f -> - DropdownMenuItem( - text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") }, - onClick = { - richter = richter.toMutableList().also { it[idx] = r.copy(name = f.name) } - showRichterDropdown = false - } - ) - } - } - } - // Qualifikation-Dropdown - var qualExpanded by remember { mutableStateOf(false) } - Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) { - OutlinedTextField( - value = r.qualifikation, - onValueChange = {}, - readOnly = true, - trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }, - modifier = Modifier.fillMaxWidth().height(44.dp), - singleLine = true, - ) - DropdownMenu(expanded = qualExpanded, onDismissRequest = { qualExpanded = false }) { - listOf("D-GP", "D-M", "D-L", "S-GP", "S-M").forEach { q -> - DropdownMenuItem(text = { Text(q) }, onClick = { - richter = richter.toMutableList().also { it[idx] = r.copy(qualifikation = q) } - qualExpanded = false - }) - } - } - } - // Funktion-Dropdown - var funExpanded by remember { mutableStateOf(false) } - Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) { - OutlinedTextField( - value = r.funktion, - onValueChange = {}, - readOnly = true, - trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }, - modifier = Modifier.fillMaxWidth().height(44.dp), - singleLine = true, - ) - DropdownMenu(expanded = funExpanded, onDismissRequest = { funExpanded = false }) { - listOf("Hauptrichter", "Beisitzer", "Schreiber").forEach { f -> - DropdownMenuItem(text = { Text(f) }, onClick = { - richter = richter.toMutableList().also { it[idx] = r.copy(funktion = f) } - funExpanded = false - }) - } - } - } - IconButton( - onClick = { richter = richter.toMutableList().also { it.removeAt(idx) } }, - modifier = Modifier.size(44.dp), - ) { - Icon( - Icons.Default.Delete, - contentDescription = "Löschen", - tint = DeleteRed, - modifier = Modifier.size(18.dp) - ) - } - } - } - } - - // ── Austragungsplätze ──────────────────────────────────────────────── - OrgSectionCard( - title = "Austragungsplätze", - ) { - OrgSubSection( - title = "Plätze & Anlagen", - action = { - TextButton(onClick = { - plaetze = plaetze + AustragungsplatzUiModel("", "", "") - }) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue) - Spacer(Modifier.width(4.dp)) - Text("Platz hinzufügen", color = AccentBlue, fontSize = 13.sp) - } - }, - ) { - Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) { - Text("Sparte", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f)) - Text("Größe", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f)) - Text("Bezeichnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f)) - Text("Aktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(48.dp)) - } - HorizontalDivider() - plaetze.forEachIndexed { idx, p -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - var sparteExpanded by remember { mutableStateOf(false) } - Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) { - OutlinedTextField( - value = p.sparte, - onValueChange = {}, - readOnly = true, - trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }, - modifier = Modifier.fillMaxWidth().height(44.dp), - singleLine = true, - ) - DropdownMenu(expanded = sparteExpanded, onDismissRequest = { sparteExpanded = false }) { - listOf("Dressur", "Springen", "Vielseitigkeit").forEach { s -> - DropdownMenuItem(text = { Text(s) }, onClick = { - plaetze = plaetze.toMutableList().also { it[idx] = p.copy(sparte = s) } - sparteExpanded = false - }) - } - } - } - var groesseExpanded by remember { mutableStateOf(false) } - Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) { - OutlinedTextField( - value = p.groesse, - onValueChange = {}, - readOnly = true, - trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }, - modifier = Modifier.fillMaxWidth().height(44.dp), - singleLine = true, - ) - DropdownMenu(expanded = groesseExpanded, onDismissRequest = { groesseExpanded = false }) { - listOf("20 x 60 m", "20 x 40 m", "60 x 80 m").forEach { g -> - DropdownMenuItem(text = { Text(g) }, onClick = { - plaetze = plaetze.toMutableList().also { it[idx] = p.copy(groesse = g) } - groesseExpanded = false - }) - } - } - } - OutlinedTextField( - value = p.bezeichnung, - onValueChange = { v -> plaetze = plaetze.toMutableList().also { it[idx] = p.copy(bezeichnung = v) } }, - modifier = Modifier.weight(3f).height(44.dp).padding(end = 8.dp), - singleLine = true, - ) - IconButton( - onClick = { plaetze = plaetze.toMutableList().also { it.removeAt(idx) } }, - modifier = Modifier.size(44.dp), - ) { - Icon( - Icons.Default.Delete, - contentDescription = "Löschen", - tint = DeleteRed, - modifier = Modifier.size(18.dp) - ) - } - } - } - } - } - - // ── Speichern ──────────────────────────────────────────────────────── - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - Button( - onClick = {}, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), - ) { Text("Speichern") } - } - } -} - -@Composable -private fun OrgSectionCard( - title: String, - action: @Composable (() -> Unit)? = null, - content: @Composable ColumnScope.() -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = Color.White), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), - ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue) - action?.invoke() - } - content() - } - } -} - -@Composable -private fun OrgSubSection( - title: String, - action: @Composable (() -> Unit)? = null, - content: @Composable ColumnScope.() -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = Color(0xFFF9FAFB)), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - action?.invoke() - } - content() - } - } -} - -@Composable -private fun OrgSearchField(label: String, value: String, onValueChange: (String) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - label, - fontSize = 13.sp, - modifier = Modifier.weight(1.5f), // Flexibles Gewicht statt fixen 200dp - color = Color(0xFF374151) - ) - OutlinedTextField( - value = value, - onValueChange = onValueChange, - placeholder = { Text("Name suchen...", fontSize = 12.sp) }, - modifier = Modifier.weight(3f), // Flexibles Gewicht und keine fixe Höhe - singleLine = true, - ) - } -} - -// --- UI-Modelle --- - -data class RichterUiModel(val name: String, val qualifikation: String, val funktion: String) -data class AustragungsplatzUiModel(val sparte: String, val groesse: String, val bezeichnung: String) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt deleted file mode 100644 index d81d9437..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt +++ /dev/null @@ -1,631 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import java.time.LocalDate - -private val PrimaryBlue = Color(0xFF1E3A8A) -private val AccentBlue = Color(0xFF3B82F6) - -/** - * STAMMDATEN-Tab im TurnierDetailScreen. - * Gemäß Figma Vision_03 (figma-entwurf_16 / figma-entwurf_15): - * - Turnier-Konfiguration: Nr., Typ (OTO/FEI), ZNS-Import, Sprache - * - Sparten-Checkboxen, Klassen, Kategorien, Datum - * - Turnier-Beschreibung: Titel, Sub-Titel - * - Sponsoren - */ - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun StammdatenTabContent( - turnierId: Long, - eventVon: String? = null, - eventBis: String? = null, - eventOrt: String? = null, - veranstalterName: String? = null, - veranstalterOrt: String? = null, - veranstalterBundesland: String? = null, - veranstalterLogoUrl: String? = null, -) { - // In einer echten App würden wir diese Daten aus einem ViewModel laden. - // Hier simulieren wir den State basierend auf den Anforderungen. - - var turnierNr by remember { mutableStateOf("") } - var nrConfirmed by remember { mutableStateOf(false) } - var showNrConfirm by remember { mutableStateOf(false) } - var znsDataLoaded by remember { mutableStateOf(false) } - var znsPayloadVersion by remember { mutableStateOf(null) } - var znsImportedAt by remember { mutableStateOf(null) } - val znsImportHistory = - remember { mutableStateListOf>() } // (source, payloadVersion, ok) - var typ by remember { mutableStateOf("ÖTO (National)") } - - val sparten = remember { mutableStateListOf() } - val klassen = remember { mutableStateListOf() } - val kat = remember { mutableStateListOf() } - - var von by remember { mutableStateOf(eventVon ?: "") } - var bis by remember { mutableStateOf(eventBis ?: "") } - var ort by remember { mutableStateOf(eventOrt ?: "") } - - var titel by remember { mutableStateOf("") } - var subTitel by remember { mutableStateOf("") } - - // Initialisierung aus Mock-Store (`StoreV2/TurnierStoreV2`) falls vorhanden - LaunchedEffect(turnierId) { - // Da wir in einem anderen Modul sind, können wir nicht direkt auf StoreV2 zugreifen, - // ohne die Abhängigkeit zu haben. In einer echten Architektur kommt dies über das Repository. - // Aber für die Demo/Fakten-Präsentation im Desktop-Shell-Kontext: - try { - val clazz = Class.forName("at.mocode.frontend.shell.desktop.data.TurnierStore") - val method = clazz.getMethod("allTurniere") - val all = method.invoke(null) as? List<*> - val turnier = all?.find { t -> - val idField = t!!::class.java.getDeclaredField("turnierNr") - idField.isAccessible = true - idField.get(t).toString() == turnierId.toString() || - t.hashCode().toLong() == turnierId // Fallback, falls die ID anders gemappt ist - } - - when { - turnier != null -> { - val tClass = turnier::class.java - - val nrField = tClass.getDeclaredField("turnierNr") - nrField.isAccessible = true - turnierNr = nrField.get(turnier).toString() - nrConfirmed = true - - val titelField = tClass.getDeclaredField("titel") - titelField.isAccessible = true - titel = titelField.get(turnier) as String - - val subField = tClass.getDeclaredField("subTitel") - subField.isAccessible = true - subTitel = subField.get(turnier) as String - - val katField = tClass.getDeclaredField("kategorie") - katField.isAccessible = true - val kats = katField.get(turnier) as? List - kats?.let { - kat.clear() - kat.addAll(it) - } - - val typField = tClass.getDeclaredField("typ") - typField.isAccessible = true - typ = typField.get(turnier) as String - - val znsField = tClass.getDeclaredField("znsDataLoaded") - znsField.isAccessible = true - znsDataLoaded = znsField.get(turnier) as Boolean - } - } - } catch (_: Exception) { - // Reflection fehlgeschlagen oder Store nicht erreichbar -> Fallback auf leere Felder - } - } - var turnierLogoUrl by remember { mutableStateOf("") } - val sponsoren = remember { mutableStateListOf() } - - var showZnsDialog by remember { mutableStateOf(false) } - var showZnsLog by remember { mutableStateOf(false) } - - // Hilf's-States für DatePicker - var showDatePickerVon by remember { mutableStateOf(false) } - var showDatePickerBis by remember { mutableStateOf(false) } - - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - // ── Turnier-Konfiguration (Schritt 1 Logik) ─────────────────────────── - SectionCard(title = "Turnier-Konfiguration & ZNS") { - FormRow("Turnier-Nr.:") { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = turnierNr, - onValueChange = { if (it.length <= 5 && it.all { c -> c.isDigit() }) turnierNr = it }, - placeholder = { Text("5-stellig", fontSize = 13.sp) }, - modifier = Modifier.width(120.dp), - singleLine = true, - enabled = !nrConfirmed - ) - when { - !nrConfirmed -> { - Button( - onClick = { showNrConfirm = true }, - enabled = turnierNr.length == 5, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) - ) { - Text("Bestätigen") - } - } - - else -> { - InputChip( - selected = true, - onClick = { }, - label = { Text("Bestätigt") }, - trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } - ) - } - } - } - when (turnierNr.length) { - 5 if !nrConfirmed -> { - Text( - "Bitte Turnier-Nummer bestätigen um fortzufahren.", - color = MaterialTheme.colorScheme.error, - fontSize = 11.sp - ) - } - } - } - - FormRow("Typ:") { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = typ == "ÖTO (National)", - onClick = { typ = "ÖTO (National)" }, - enabled = nrConfirmed, - label = { Text("ÖTO (National)") } - ) - FilterChip( - selected = typ == "FEI (International)", - onClick = { typ = "FEI (International)" }, - enabled = nrConfirmed, - label = { Text("FEI (International)") } - ) - } - } - - FormRow("ZNS-Stammdaten:") { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Button( - onClick = { showZnsDialog = true }, - colors = ButtonDefaults.buttonColors(containerColor = AccentBlue), enabled = nrConfirmed - ) { - Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Import via Internet") - } - OutlinedButton(onClick = { showZnsDialog = true }, enabled = nrConfirmed) { - Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Import via USB") - } - TextButton(onClick = { showZnsLog = true }, enabled = nrConfirmed) { Text("Import-Log anzeigen…") } - } - - val znsStatusColor = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Error, - contentDescription = null, - tint = znsStatusColor, - modifier = Modifier.size(16.dp) - ) - Text( - if (znsDataLoaded) "ZNS-Daten geladen" else "Keine ZNS-Daten geladen", - color = znsStatusColor, - fontWeight = FontWeight.Bold, - fontSize = 13.sp - ) - if (znsDataLoaded) { - Spacer(Modifier.width(8.dp)) - Text( - listOfNotNull( - znsPayloadVersion?.let { "Version: $it" }, - znsImportedAt?.let { "Zeit: $it" }, - ).joinToString(" • "), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp - ) - } - } - } - } - - // ── Sparten & Kategorien (Schritt 2 Logik) ─────────────────────────── - SectionCard(title = "Reglement & Sparten") { - FormRow("Sparte:") { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip( - selected = sparten.contains("Dressur"), - onClick = { if (sparten.contains("Dressur")) sparten.remove("Dressur") else sparten.add("Dressur") }, - enabled = nrConfirmed, - label = { Text("Dressur") } - ) - FilterChip( - selected = sparten.contains("Springen"), - onClick = { if (sparten.contains("Springen")) sparten.remove("Springen") else sparten.add("Springen") }, - enabled = nrConfirmed, - label = { Text("Springen") } - ) - } - } - - FormRow("Klasse:") { - val klassenListe = listOf("C-NEU", "C", "B", "A") - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - klassenListe.forEach { k -> - FilterChip( - selected = klassen.contains(k), - onClick = { if (klassen.contains(k)) klassen.remove(k) else klassen.add(k) }, - enabled = nrConfirmed, - label = { Text(k) } - ) - } - } - } - - FormRow("Kategorien:") { - // Logik zur Generierung der Kategorien - val suggested = mutableListOf() - sparten.forEach { s -> - val prefix = if (s == "Dressur") "CDN" else "CSN" - klassen.forEach { k -> - suggested.add("$prefix-$k") - suggested.add("${prefix}P-$k") // Pony Variante - } - } - - when { - suggested.isEmpty() -> { - Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp) - } - - else -> { - // Gruppiere nach Sparte (CDN/CSN) - val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" } - grouped.forEach { (gruppe, eintraege) -> - Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue) - Spacer(Modifier.height(4.dp)) - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - eintraege.sorted().forEach { c -> - InputChip( - selected = kat.contains(c), - onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) }, - enabled = nrConfirmed, - label = { Text(c) } - ) - } - } - Spacer(Modifier.height(8.dp)) - } - } - } - } - - FormRow("Zeitraum:") { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - val vonMod = - if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp) - OutlinedTextField( - value = von, - onValueChange = {}, - label = { Text("Von") }, - modifier = vonMod, - readOnly = true, - enabled = nrConfirmed, - trailingIcon = { Icon(Icons.Default.DateRange, null) } - ) - Text("bis") - val bisMod = - if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp) - OutlinedTextField( - value = bis, - onValueChange = {}, - label = { Text("Bis") }, - modifier = bisMod, - readOnly = true, - enabled = nrConfirmed, - trailingIcon = { Icon(Icons.Default.DateRange, null) } - ) - } - val rangeText = - if (eventVon != null && eventBis != null) "Muss zwischen $eventVon – $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen." - Text(rangeText, fontSize = 11.sp, color = Color.Gray) - } - } - - // ── Branding (Schritt 3 Logik) ─────────────────────────────────────── - SectionCard(title = "Turnier-Branding") { - // Default-Titel-Vorschlag: [Kategorien] [Verein-Ort] [Bundesland] - val defaultTitle = remember(kat.size, veranstalterOrt, veranstalterBundesland) { - val cats = if (kat.isEmpty()) "" else kat.sorted().joinToString(" ") - listOfNotNull( - cats.ifBlank { null }, - listOfNotNull(veranstalterOrt, veranstalterBundesland).filter { it.isNotBlank() }.joinToString(" ") - .takeIf { it.isNotBlank() } - ).joinToString(" ") - } - OutlinedTextField( - value = titel, - onValueChange = { titel = it }, - label = { Text("Titel") }, - placeholder = { if (defaultTitle.isNotBlank()) Text(defaultTitle) }, - enabled = nrConfirmed, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = subTitel, - onValueChange = { subTitel = it }, - label = { Text("Sub-Titel") }, - enabled = nrConfirmed, - modifier = Modifier.fillMaxWidth() - ) - - // Ort im Branding-Bereich platzieren (mit Soft-Warnung bei Abweichung zum Veranstaltungsort) - FormRow("Ort:") { - OutlinedTextField( - value = ort, - onValueChange = { ort = it }, - label = { Text("Austragungsort") }, - enabled = nrConfirmed, - modifier = Modifier.fillMaxWidth(), - supportingText = { - if (eventOrt != null && ort.isNotBlank() && ort.trim() != eventOrt.trim()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Warning, - contentDescription = null, - tint = Color(0xFFF59E0B), - modifier = Modifier.size(14.dp) - ) - Spacer(Modifier.width(4.dp)) - Text("Abweichung zum Veranstaltungsort ($eventOrt) – bitte prüfen.", color = Color(0xFFF59E0B)) - } - } else { - Text("Muss mit Veranstaltungsort übereinstimmen.") - } - } - ) - } - - // Turnier-Logo mit Fallback auf Veranstalterlogo - OutlinedTextField( - value = turnierLogoUrl, - onValueChange = { turnierLogoUrl = it }, - label = { Text("Turnier-Logo (URL/Pfad)") }, - enabled = nrConfirmed, - supportingText = { - Text("Wenn leer: verwende Veranstalter-Logo${if (!veranstalterLogoUrl.isNullOrBlank()) " ($veranstalterLogoUrl)" else ""}.") - }, - modifier = Modifier.fillMaxWidth() - ) - - FormRow("Sponsoren:") { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - sponsoren.forEach { s -> - InputChip( - selected = true, - onClick = { sponsoren.remove(s) }, - enabled = nrConfirmed, - label = { Text(s) }, - trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(14.dp)) } - ) - } - TextButton(onClick = { sponsoren.add("Neuer Sponsor") }, enabled = nrConfirmed) { - Text("+ Hinzufügen") - } - } - } - } - - // ── Footer ────────────────────────────────────────────────────────── - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Save-Enable-Matrix (kleine Checkliste) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - AssistChip(onClick = {}, label = { Text("Nr bestätigt") }, leadingIcon = { - Icon( - if (nrConfirmed) Icons.Default.Check else Icons.Default.Close, - null, - tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error - ) - }) - AssistChip(onClick = {}, label = { Text("ZNS geladen") }, leadingIcon = { - Icon( - if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close, - null, - tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error - ) - }) - val dateOk = remember(von, bis, eventVon, eventBis) { - try { - if (eventVon == null || eventBis == null || von.isBlank()) true else { - val evV = LocalDate.parse(eventVon) - val evB = LocalDate.parse(eventBis) - val tV = LocalDate.parse(von) - val tB = if (bis.isBlank()) tV else LocalDate.parse(bis) - !tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV) - } - } catch (_: Exception) { - false - } - } - AssistChip(onClick = {}, label = { Text("Datum gültig") }, leadingIcon = { - Icon( - if (dateOk) Icons.Default.Check else Icons.Default.Close, - null, - tint = if (dateOk) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error - ) - }) - } - - Button( - onClick = { /* Speichern */ }, - enabled = run { - val base = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank() - val dateValid = try { - if (eventVon == null || eventBis == null || von.isBlank()) true else { - val evV = LocalDate.parse(eventVon) - val evB = LocalDate.parse(eventBis) - val tV = LocalDate.parse(von) - val tB = if (bis.isBlank()) tV else LocalDate.parse(bis) - !tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV) - } - } catch (_: Exception) { - false - } - base && dateValid - }, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), - modifier = Modifier.padding(bottom = 24.dp) - ) { - Icon(Icons.Default.Save, null) - Spacer(Modifier.width(8.dp)) - Text("Änderungen speichern") - } - } - } - - // Dialog-Simulationen - when { - showZnsDialog -> { - AlertDialog( - onDismissRequest = { showZnsDialog = false }, - title = { Text("ZNS Import") }, - text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") }, - confirmButton = { - TextButton(onClick = { - znsDataLoaded = true - znsPayloadVersion = "v2.4" - znsImportedAt = java.time.Instant.now().toString() - znsImportHistory.add(Triple("Internet/USB", znsPayloadVersion!!, true)) - showZnsDialog = false - }) { Text("Importieren") } - }, - dismissButton = { - TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") } - } - ) - } - } - - when { - showNrConfirm -> { - AlertDialog( - onDismissRequest = { showNrConfirm = false }, - title = { Text("Turnier-Nummer bestätigen?") }, - text = { Text("Die Turnier-Nr. ist nach der Bestätigung nicht mehr änderbar.") }, - confirmButton = { - TextButton(onClick = { nrConfirmed = true; showNrConfirm = false }) { Text("Ja, bestätigen") } - }, - dismissButton = { - TextButton(onClick = { showNrConfirm = false }) { Text("Abbrechen") } - } - ) - } - } - - when { - showZnsLog -> { - AlertDialog( - onDismissRequest = { showZnsLog = false }, - title = { Text("ZNS Import-Log (letzte 5)") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - if (znsImportHistory.isEmpty()) { - Text("Keine Einträge vorhanden.", color = Color.Gray) - } else { - znsImportHistory.takeLast(5).asReversed().forEach { (src, ver, ok) -> - val c = if (ok) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error - Text("• $src – Version $ver – ${if (ok) "OK" else "Fehler"}", color = c, fontSize = 13.sp) - } - } - } - }, - confirmButton = { TextButton(onClick = { showZnsLog = false }) { Text("Schließen") } } - ) - } - } - - when { - showDatePickerVon -> { - val state = rememberDatePickerState() - DatePickerDialog( - onDismissRequest = { showDatePickerVon = false }, - confirmButton = { - TextButton(onClick = { - state.selectedDateMillis?.let { - von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString() - } - showDatePickerVon = false - }) { Text("OK") } - } - ) { DatePicker(state) } - } - - showDatePickerBis -> { - val state = rememberDatePickerState() - DatePickerDialog( - onDismissRequest = { showDatePickerBis = false }, - confirmButton = { - TextButton(onClick = { - state.selectedDateMillis?.let { - bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString() - } - showDatePickerBis = false - }) { Text("OK") } - } - ) { DatePicker(state) } - } - } -} - -@Composable -private fun SectionCard( - title: String, - content: @Composable ColumnScope.() -> Unit -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = Color.White), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text(title, style = MaterialTheme.typography.titleLarge, color = PrimaryBlue, fontWeight = FontWeight.Bold) - HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) - content() - } - } -} - -@Composable -private fun FormRow(label: String, content: @Composable ColumnScope.() -> Unit) { - Row(Modifier.fillMaxWidth()) { - Text( - label, - modifier = Modifier.width(140.dp).padding(top = 12.dp), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold - ) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { - content() - } - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStartlistenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStartlistenTab.kt deleted file mode 100644 index c6418a31..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStartlistenTab.kt +++ /dev/null @@ -1,241 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -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.turnier.feature.domain.model.StartlistenZeile -import at.mocode.turnier.feature.domain.Bewerb -import org.koin.compose.koinInject - -private val SlBlue = Color(0xFF1E3A8A) -private val SlHeaderBg = Color(0xFFF1F5F9) - -/** - * STARTLISTEN-Tab gemäß Vision_03. - * - * Layout: 2-spaltig - * - Links (flex): Bewerbs-Tabs + Starter-Tabelle (Startnr | Pferd | Reiter | Abteilung | Beginn) - * - Rechts (280dp): Sortierung & Zeit-Panel - */ -@Composable -fun StartlistenTabContent( - viewModel: BewerbViewModel = koinInject() -) { - val state by viewModel.state.collectAsState() - val selectedBewerb = state.list.find { it.id == state.selectedId } - - Row(modifier = Modifier.fillMaxSize()) { - // ── Linke Spalte: Bewerbs-Tabs + Tabelle ───────────────────────────── - Column(modifier = Modifier.weight(1f).fillMaxHeight()) { - StartlistenBewerbsTabs( - bewerbe = state.list, - selectedId = state.selectedId, - onSelect = { viewModel.send(BewerbIntent.Select(it)) }, - currentStartliste = state.currentStartliste, - onGenerate = { viewModel.generateStartliste() }, - onRowClick = { viewModel.send(BewerbIntent.OpenErgebnisEdit(it)) } - ) - } - - VerticalDivider() - - // ── Rechte Spalte: Sortierung & Zeit ───────────────────────────────── - StartlistenSortierPanel(modifier = Modifier.width(280.dp).fillMaxHeight()) - } - - // Ergebnis-Dialog - state.editingErgebnis?.let { ergebnis -> - val zeile = state.selectedZeile - if (zeile != null) { - ErgebnisEditDialog( - ergebnis = ergebnis, - reiterName = zeile.reiter, - pferdName = zeile.pferd, - onDismiss = { viewModel.send(BewerbIntent.CloseErgebnisEdit) }, - onSave = { viewModel.send(BewerbIntent.SaveErgebnis(it)) } - ) - } - } -} - -@Composable -private fun StartlistenBewerbsTabs( - bewerbe: List, - selectedId: Long?, - onSelect: (Long?) -> Unit, - currentStartliste: List, - onGenerate: () -> Unit, - onRowClick: (StartlistenZeile) -> Unit -) { - val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0) - - PrimaryScrollableTabRow( - selectedTabIndex = selectedIndex, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = SlBlue, - edgePadding = 0.dp, - ) { - bewerbe.forEachIndexed { index, bewerb -> - Tab( - selected = selectedId == bewerb.id, - onClick = { onSelect(bewerb.id) }, - text = { Text(bewerb.tag, fontSize = 12.sp) }, - ) - } - } - HorizontalDivider() - - val selectedBewerb = bewerbe.getOrNull(selectedIndex) - - // Toolbar - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = selectedBewerb?.let { "${it.tag} – ${it.name}" } ?: "Kein Bewerb ausgewählt", - fontWeight = FontWeight.SemiBold, - fontSize = 13.sp, - ) - Spacer(Modifier.weight(1f)) - OutlinedButton( - onClick = {}, - modifier = Modifier.height(32.dp), - contentPadding = PaddingValues(horizontal = 10.dp) - ) { - Text("Drucken", fontSize = 12.sp) - } - OutlinedButton( - onClick = {}, - modifier = Modifier.height(32.dp), - contentPadding = PaddingValues(horizontal = 10.dp) - ) { - Text("Exportieren", fontSize = 12.sp) - } - } - - // Tabellen-Header - Row( - modifier = Modifier.fillMaxWidth().background(SlHeaderBg).padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp)) - Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("Abteilung", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp)) - Text("Beginn", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp)) - } - HorizontalDivider() - - if (currentStartliste.isEmpty()) { - // Leere Liste - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Keine Starter vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280)) - Spacer(Modifier.height(8.dp)) - Text("Startliste wird nach Nennungsschluss generiert.", fontSize = 12.sp, color = Color(0xFF9CA3AF)) - Spacer(Modifier.height(16.dp)) - Button( - onClick = onGenerate, - colors = ButtonDefaults.buttonColors(containerColor = SlBlue), - ) { - Text("Startliste generieren", fontSize = 13.sp) - } - } - } - } else { - // Liste anzeigen - androidx.compose.foundation.lazy.LazyColumn(modifier = Modifier.fillMaxSize()) { - items(currentStartliste.size) { index -> - val zeile = currentStartliste[index] - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onRowClick(zeile) } - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(zeile.nr.toString(), fontSize = 12.sp, modifier = Modifier.width(70.dp)) - Text(zeile.pferd, fontSize = 12.sp, modifier = Modifier.weight(1f)) - Text(zeile.reiter, fontSize = 12.sp, modifier = Modifier.weight(1f)) - Text("-", fontSize = 12.sp, modifier = Modifier.width(80.dp)) - Text(zeile.zeit, fontSize = 12.sp, modifier = Modifier.width(70.dp)) - } - HorizontalDivider(color = Color(0xFFE5E7EB)) - } - } - } -} - -@Composable -private fun StartlistenSortierPanel(modifier: Modifier = Modifier) { - Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Sortierung & Zeit", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) - HorizontalDivider() - - // Sortierung - Text("Sortierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - SortierOption("Aufsteigend (Startnummer)") - SortierOption("Absteigend (Startnummer)") - SortierOption("Auslosung (zufällig)") - SortierOption("Alphabetisch (Pferd)") - SortierOption("Alphabetisch (Reiter)") - } - - HorizontalDivider() - - // Zeiten - Text("Zeiten", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium) - LabeledInput("Beginnzeit:", "08:00", "(hh:mm)") - LabeledInput("Reitdauer:", "02:00", "(mm:ss)") - LabeledInput("Umbau:", "10", "(mm)") - LabeledInput("Besichtigung:", "10", "(mm)") - - HorizontalDivider() - - Button( - onClick = {}, - colors = ButtonDefaults.buttonColors(containerColor = SlBlue), - modifier = Modifier.fillMaxWidth(), - ) { - Text("Zeiten neu berechnen", fontSize = 12.sp) - } - } -} - -@Composable -private fun SortierOption(label: String) { - var selected by remember { mutableStateOf(false) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - ) { - RadioButton(selected = selected, onClick = { selected = !selected }) - Text(label, fontSize = 12.sp) - } -} - -@Composable -private fun LabeledInput(label: String, value: String, unit: String) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(label, fontSize = 12.sp, modifier = Modifier.width(100.dp)) - OutlinedTextField( - value = value, - onValueChange = {}, - modifier = Modifier.width(70.dp), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 12.sp), - ) - Spacer(Modifier.width(6.dp)) - Text(unit, fontSize = 11.sp, color = Color(0xFF6B7280)) - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierViewModel.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierViewModel.kt deleted file mode 100644 index 9288e9c8..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierViewModel.kt +++ /dev/null @@ -1,106 +0,0 @@ -package at.mocode.turnier.feature.presentation - -import at.mocode.turnier.feature.domain.Turnier -import at.mocode.turnier.feature.domain.TurnierRepository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -// UI-optimiertes List Item für Turniere (unabhängig vom Domänenmodell) -data class TurnierListItem( - val id: Long, - val name: String, - val ort: String, - val startDatum: String, - val endDatum: String, - val status: String, -) - -data class TurnierState( - val isLoading: Boolean = false, - val searchQuery: String = "", - val list: List = emptyList(), - val filtered: List = emptyList(), - val selectedId: Long? = null, - val errorMessage: String? = null, -) - -sealed interface TurnierIntent { - data object Load : TurnierIntent - data object Refresh : TurnierIntent - data class SearchChanged(val query: String) : TurnierIntent - data class Select(val id: Long?) : TurnierIntent - data object ClearError : TurnierIntent -} - -class TurnierViewModel( - private val repo: TurnierRepository, -) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - private val _state = MutableStateFlow(TurnierState(isLoading = true)) - val state: StateFlow = _state - - init { - send(TurnierIntent.Load) - } - - fun send(intent: TurnierIntent) { - when (intent) { - is TurnierIntent.Load -> load() - is TurnierIntent.Refresh -> load() - is TurnierIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() } - is TurnierIntent.Select -> reduce { it.copy(selectedId = intent.id) } - is TurnierIntent.ClearError -> reduce { it.copy(errorMessage = null) } - } - } - - private fun load() { - reduce { it.copy(isLoading = true, errorMessage = null) } - scope.launch { - repo.list() - .onSuccess { list -> - val items = list.map { it.toListItem() } - reduce { cur -> - val filtered = filterList(items, cur.searchQuery) - cur.copy(isLoading = false, list = items, filtered = filtered) - } - } - .onFailure { t -> - reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") } - } - } - } - - private fun Turnier.toListItem() = TurnierListItem( - id = id, - name = name, - ort = "Stadl-Paura", // Platzhalter bis API erweitert - startDatum = "2026-05-01", - endDatum = "2026-05-03", - status = "AKTIV" - ) - - private fun filter() { - val cur = _state.value - val filtered = filterList(cur.list, cur.searchQuery) - reduce { it.copy(filtered = filtered) } - } - - private fun filterList(list: List, query: String): List { - if (query.isBlank()) return list - val q = query.trim() - return list.filter { - it.name.contains(q, ignoreCase = true) || - it.ort.contains(q, ignoreCase = true) || - it.status.contains(q, ignoreCase = true) - } - } - - private inline fun reduce(block: (TurnierState) -> TurnierState) { - _state.value = block(_state.value) - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt deleted file mode 100644 index 5e11054f..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt +++ /dev/null @@ -1,152 +0,0 @@ -package at.mocode.turnier.feature.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) - -/** - * TurnierWizardV2 - Der neue Wizard für die Turnieranlage (Vision_03). - */ -@Composable -fun TurnierWizardV2( - veranstaltungId: Long, - onBack: () -> Unit, - onSave: () -> Unit, -) { - var currentStep by remember { mutableIntStateOf(0) } - val steps = listOf("Stammdaten", "Organisation", "Bewerbe ⭐", "Preisliste") - - Column(modifier = Modifier.fillMaxSize().background(Color.White)) { - // Wizard 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, null) } - Spacer(Modifier.width(8.dp)) - Text("Neues Turnier anlegen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - } - Button( - onClick = onSave, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), - enabled = currentStep == steps.size - 1 - ) { - Text("Turnier finalisieren") - } - } - - Spacer(Modifier.height(16.dp)) - - // Stepper UI - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - steps.forEachIndexed { index, title -> - StepItem( - title = title, - isActive = index == currentStep, - isCompleted = index < currentStep, - modifier = Modifier.weight(1f) - ) - if (index < steps.size - 1) { - Box( - modifier = Modifier.height(2.dp).weight(0.5f) - .background(if (index < currentStep) PrimaryBlue else Color.LightGray) - ) - } - } - } - } - } - - // Content Area - Box(modifier = Modifier.weight(1f).padding(32.dp)) { - when (currentStep) { - 0 -> PlaceholderContent("Stammdaten", "Hier werden OEPS-Nummer, Kategorie und Sparte konfiguriert.") - 1 -> PlaceholderContent("Organisation", "Zuweisung von Richtern, Parcourschefs und Tierärzten.") - 2 -> PlaceholderContent("Bewerbe", "Konfiguration der Bewerbe und Abteilungen gemäß § 39 ÖTO.") - 3 -> PlaceholderContent("Preisliste", "Einstellung der Nenngebühren und Sportförderbeiträge (Billing-Sync).") - } - } - - // Wizard Navigation - Surface(shadowElevation = 8.dp, color = Color.White) { - Row( - modifier = Modifier.fillMaxWidth().padding(24.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton( - onClick = { if (currentStep > 0) currentStep-- else onBack() } - ) { - Text(if (currentStep == 0) "Abbrechen" else "Zurück") - } - - if (currentStep < steps.size - 1) { - Button( - onClick = { currentStep++ }, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) - ) { - Text("Weiter") - Spacer(Modifier.width(8.dp)) - Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(16.dp)) - } - } - } - } - } -} - -@Composable -private fun StepItem(title: String, isActive: Boolean, isCompleted: Boolean, modifier: Modifier = Modifier) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { - Box( - modifier = Modifier - .size(32.dp) - .background( - color = when { - isCompleted -> PrimaryBlue - 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(16.dp)) - } else { - Text( - text = "", // Or step number - color = Color.White, - style = MaterialTheme.typography.bodySmall - ) - } - } - Text( - text = title, - style = MaterialTheme.typography.bodySmall, - fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, - color = if (isActive || isCompleted) PrimaryBlue else Color.Gray, - modifier = Modifier.padding(top = 4.dp) - ) - } -} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt deleted file mode 100644 index 155aaccc..00000000 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt +++ /dev/null @@ -1,412 +0,0 @@ -package at.mocode.turnier.feature.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.turnier.feature.domain.AuditLogEntry) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(ZeitplanBg, RoundedCornerShape(4.dp)) - .padding(8.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(6.dp).background(ZeitplanBlue, RoundedCornerShape(3.dp))) - Spacer(Modifier.width(8.dp)) - Text( - text = entry.action, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = Color.Black - ) - Spacer(Modifier.weight(1f)) - Text( - text = entry.timestamp.split("T").lastOrNull()?.take(5) ?: "", - fontSize = 10.sp, - color = HourLabelColor - ) - } - if (entry.changesJson != null) { - Spacer(Modifier.height(4.dp)) - Text( - text = entry.changesJson, - fontSize = 10.sp, - color = Color.DarkGray, - lineHeight = 14.sp - ) - } - } -} - -@Composable -private fun ZeitAchse() { - Column( - modifier = Modifier - .width(60.dp) - .fillMaxHeight() - .background(Color.White) - ) { - Box(modifier = Modifier.fillMaxHeight().width(59.dp).background(Color.White)) { - Column { - for (hour in START_HOUR..END_HOUR) { - Box( - modifier = Modifier.height(HOUR_HEIGHT).fillMaxWidth(), - contentAlignment = Alignment.TopCenter - ) { - Text( - text = "${hour.toString().padStart(2, '0')}:00", - fontSize = 11.sp, - color = HourLabelColor, - modifier = Modifier.padding(top = 4.dp) - ) - } - } - } - } - } -} - -@Composable -private fun ZeitplanGitter() { - Column { - for (hour in START_HOUR..END_HOUR) { - Box( - modifier = Modifier - .height(HOUR_HEIGHT) - .fillMaxWidth() - ) { - HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter), color = SlotBorder) - } - } - } -} - -@Composable -private fun DraggableBewerbBox( - item: ZeitplanItemUi, - onPositionChange: (Int) -> Unit, - onClick: () -> Unit = {} -) { - // Berechnung der Position basierend auf den Startminuten seit START_HOUR - val relativeMinutes = item.startMinutes - (START_HOUR * 60) - val topOffset = (relativeMinutes * MINUTE_HEIGHT.value).dp - val height = (item.durationMinutes * MINUTE_HEIGHT.value).dp - - var offsetX by remember { mutableStateOf(0f) } - var offsetY by remember { mutableStateOf(0f) } - - Box( - modifier = Modifier - .offset(y = topOffset) - .padding(horizontal = 8.dp) - .fillMaxWidth() - .height(height) - .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } - .clip(RoundedCornerShape(6.dp)) - .background(item.color.copy(alpha = 0.15f)) - .border(1.dp, item.color, RoundedCornerShape(6.dp)) - .clickable(onClick = onClick) - .pointerInput(Unit) { - detectDragGestures( - onDragEnd = { - // Snapping auf 5 Minuten Intervalle - val movedMinutes = (offsetY / MINUTE_HEIGHT.toPx()).roundToInt() - val newTotalMinutes = item.startMinutes + movedMinutes - val snappedMinutes = (newTotalMinutes / 5) * 5 - - onPositionChange(snappedMinutes) - offsetX = 0f - offsetY = 0f - }, - onDrag = { change, dragAmount -> - change.consume() - // Nur vertikales Dragging für den Zeitplan vorerst - offsetY += dragAmount.y - } - ) - } - .padding(8.dp) - ) { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = item.timeString, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = item.color - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "Bewerb ${item.nummer}", - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = Color.Black - ) - } - Text( - text = item.name, - fontSize = 12.sp, - maxLines = 1, - color = Color.DarkGray - ) - if (item.hasConflict) { - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically - ) { - Text("⚠️", fontSize = 12.sp) - Spacer(Modifier.width(4.dp)) - Text( - text = item.conflictMessage.ifEmpty { "Konflikt" }, - fontSize = 10.sp, - color = Color.Red, - fontWeight = FontWeight.Bold, - maxLines = 1 - ) - } - } - } - } -} - -data class ZeitplanItemUi( - val id: Long, - val nummer: Int, - val name: String, - val startMinutes: Int, // Minuten seit 00:00 - val durationMinutes: Int, - val color: Color = ZeitplanBlue, - val hasConflict: Boolean = false, - val conflictMessage: String = "" -) { - val timeString: String - get() { - val h = startMinutes / 60 - val m = startMinutes % 60 - return "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}" - } -} - -private fun sampleZeitplanItems() = listOf( - ZeitplanItemUi(1, 1, "Dressurreiterprüfung Reiterpass", 8 * 60, 45), - ZeitplanItemUi(2, 2, "Dressurreiterprüfung Reitenadel", 8 * 60 + 50, 60, hasConflict = true), - ZeitplanItemUi(3, 3, "Dressurprüfung Kl. A (Aufgabe A2)", 10 * 60 + 30, 90, color = Color(0xFF059669)), - ZeitplanItemUi(4, 4, "Mittagspause", 12 * 60 + 30, 45, color = Color(0xFFD97706)), - ZeitplanItemUi(5, 5, "Dressurreiterprüfung Kl. L", 13 * 60 + 30, 120, color = Color(0xFF7C3AED)), -) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt index 6dea5138..bbba7595 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt @@ -53,10 +53,10 @@ import at.mocode.frontend.features.reiter.presentation.ReiterScreen import at.mocode.frontend.features.reiter.presentation.ReiterViewModel import at.mocode.frontend.features.verein.presentation.VereinScreen import at.mocode.frontend.features.verein.presentation.VereinViewModel -import at.mocode.ping.feature.presentation.PingScreen -import at.mocode.ping.feature.presentation.PingViewModel -import at.mocode.turnier.feature.presentation.SeriesScreen -import at.mocode.turnier.feature.presentation.TurnierDetailScreen +import at.mocode.frontend.features.ping.presentation.PingScreen +import at.mocode.frontend.features.ping.presentation.PingViewModel +import at.mocode.frontend.features.turnier.presentation.SeriesScreen +import at.mocode.frontend.features.turnier.presentation.TurnierDetailScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/preview/ScreenPreviews.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/preview/ScreenPreviews.kt index 835af6bc..462998d7 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/preview/ScreenPreviews.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/preview/ScreenPreviews.kt @@ -3,14 +3,14 @@ package at.mocode.frontend.shell.desktop.screens.preview import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import at.mocode.frontend.core.designsystem.preview.ComponentPreview -import at.mocode.turnier.feature.domain.* -import at.mocode.turnier.feature.presentation.* -import at.mocode.turnier.feature.data.remote.dto.NennungEinreichenRequest +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.zns.parser.ZnsBewerb 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.turnier.feature.domain.model.StartlistenZeile +import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen // ─────────────────────────────────────────────────────────────────────────────