chore: entferne veraltete turnier-feature Artefakte und ViewModels nach Migration auf Module Structure Blueprint

This commit is contained in:
2026-04-19 17:39:28 +02:00
parent ef5d4fdc81
commit 691861a706
61 changed files with 56 additions and 8724 deletions
@@ -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.
@@ -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
@@ -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<PingEvent> {
return client.get("/api/ping/sync") {
url.parameters.append("since", since.toString())
}.body()
}
}
@@ -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<PingEvent> {
// 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<PingEvent>) {
// 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
)
}
}
}
}
@@ -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<PingEvent>
) : PingSyncService {
override suspend fun syncPings() {
// Corrected endpoint: /api/ping/sync (singular)
syncManager.performSync(repository, "/api/ping/sync")
}
}
@@ -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)
}
}
@@ -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)
)
}
}
@@ -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<LogEntry> = 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)
}
}
@@ -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<LogEntry>,
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
)
}
}
}
}
@@ -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)
}
}
@@ -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" })
}
}
@@ -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)
}
}
@@ -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<PingEvent> {
var storedEvents = mutableListOf<PingEvent>()
var latestSince: String? = null
override suspend fun getLatestSince(): String? {
return latestSince
}
override suspend fun upsert(items: List<PingEvent>) {
// 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<PingEvent> = 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<PingEvent> {
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
}
}
@@ -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<PingEvent> = 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 = {}
)
}
}
@@ -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)
@@ -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<RichterEinsatzDto> = emptyList(),
// Zeitplan
val geplantesDatum: LocalDate? = null,
@SerialName("beginnZeitTyp") val beginnZeitTyp: String? = null, // enum name
val beginnZeit: LocalTime? = null,
val reitdauerMinuten: Int? = null,
val umbauMinuten: Int? = null,
val besichtigungMinuten: Int? = null,
val stechenGeplant: Boolean = false,
// Finanzen
val startgeldCent: Long? = null,
val geldpreisAusbezahlt: Boolean = false,
)
@Serializable
data class BewerbResponse(
val id: String,
val turnierId: String,
val klasse: String,
val hoeheCm: Int? = null,
val bezeichnung: String,
// Text & Details
val beschreibung: String? = null,
val aufgabe: String? = null,
val aufgabenNummer: String? = null,
val paraGrade: String? = null,
// Ort & Funktionäre
val austragungsplatzId: String? = null,
val richterEinsaetze: List<RichterEinsatzDto> = emptyList(),
// Zeitplan
val geplantesDatum: LocalDate? = null,
@SerialName("beginnZeitTyp") val beginnZeitTyp: String? = null,
val beginnZeit: LocalTime? = null,
val reitdauerMinuten: Int? = null,
val umbauMinuten: Int? = null,
val besichtigungMinuten: Int? = null,
val stechenGeplant: Boolean = false,
// Finanzen
val startgeldCent: Long? = null,
val geldpreisAusbezahlt: Boolean = false,
)
class BewerbApi(private val apiClient: HttpClient) {
suspend fun createBewerb(turnierId: String, payload: CreateBewerbPayload): BewerbResponse =
apiClient.post("/turniere/$turnierId/bewerbe") {
contentType(ContentType.Application.Json)
setBody(payload)
}.body()
}
@@ -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<List<Abteilung>> = runCatching {
val response = client.get(ApiRoutes.Bewerbe.abteilungen(bewerbId))
when {
response.status.isSuccess() -> response.body<List<AbteilungDto>>().map { it.toDomain() }
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getById(id: Long): Result<Abteilung> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/abteilungen/$id")
when {
response.status.isSuccess() -> response.body<AbteilungDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun create(model: Abteilung): Result<Abteilung> = runCatching {
val response = client.post(ApiRoutes.Bewerbe.abteilungen(model.bewerbId)) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<AbteilungDto>().toDomain()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun update(id: Long, model: Abteilung): Result<Abteilung> = runCatching {
val response = client.put("${ApiRoutes.API_PREFIX}/abteilungen/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<AbteilungDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.API_PREFIX}/abteilungen/$id")
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
}
@@ -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<List<Bewerb>> = runCatching {
val response = client.get(ApiRoutes.Turniere.bewerbe(turnierId))
when {
response.status.isSuccess() -> response.body<List<BewerbDto>>().map { it.toDomain() }
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit> =
runCatching {
val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") {
setBody(bewerbe)
}
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getById(id: Long): Result<Bewerb> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id")
when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun create(model: Bewerb): Result<Bewerb> = runCatching {
val response = client.post(ApiRoutes.Turniere.bewerbe(model.turnierId)) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun update(id: Long, model: Bewerb): Result<Bewerb> = runCatching {
val response = client.put("${ApiRoutes.API_PREFIX}/bewerbe/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> =
runCatching {
val response = client.patch("${ApiRoutes.API_PREFIX}/bewerbe/$id/zeitplan") {
contentType(ContentType.Application.Json)
setBody(
mapOf(
"geplantesDatum" to datum,
"beginnZeit" to beginn,
"austragungsplatzId" to platzId
)
)
}
when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/audit-log")
when {
response.status.isSuccess() -> response.body<List<AuditLogEntry>>()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun exportZnsBSatz(turnierId: Long): Result<String> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/turniere/$turnierId/export/zns/b-satz")
when {
response.status.isSuccess() -> response.body<String>()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id")
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
}
@@ -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<List<Ergebnis>> = runCatching {
client.get(ApiRoutes.Results.bewerb(bewerbId)).body()
}
override suspend fun save(ergebnis: Ergebnis): Result<Ergebnis> = runCatching {
if (ergebnis.id == null) {
client.post(ApiRoutes.Results.ROOT) {
contentType(ContentType.Application.Json)
setBody(ergebnis)
}.body()
} else {
client.put("${ApiRoutes.Results.ROOT}/${ergebnis.id}") {
contentType(ContentType.Application.Json)
setBody(ergebnis)
}.body()
}
}
override suspend fun calculatePlatzierung(bewerbId: String): Result<List<Ergebnis>> = runCatching {
client.post("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/calculate").body()
}
override suspend fun exportPdf(bewerbId: String): Result<ByteArray> = runCatching {
client.get("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/pdf").body()
}
}
@@ -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<List<Reiter>> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.REITER}/search") {
parameter("q", query)
}
if (response.status.isSuccess()) {
// Wir mappen hier manuell, da die Features aktuell keine DTOs exportieren
response.body<List<ReiterApiDto>>().map { it.toDomain() }
} else emptyList()
}
override suspend fun getReiter(id: String): Result<Reiter> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.REITER}/$id")
if (response.status.isSuccess()) {
response.body<ReiterApiDto>().toDomain()
} else throw Exception("Reiter nicht gefunden")
}
override suspend fun saveReiter(reiter: Reiter): Result<Reiter> = runCatching {
val response = client.put("${ApiRoutes.Masterdata.REITER}/${reiter.id}") {
contentType(ContentType.Application.Json)
setBody(ReiterApiDto(
reiterId = reiter.id,
vorname = reiter.vorname,
nachname = reiter.nachname,
satznummer = reiter.satznummer,
vereinsName = reiter.verein,
feiId = reiter.feiId
))
}
if (response.status.isSuccess()) {
response.body<ReiterApiDto>().toDomain()
} else throw Exception("Fehler beim Speichern des Reiters")
}
override suspend fun searchPferde(query: String): Result<List<Pferd>> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.PFERDE}/search") {
parameter("q", query)
}
if (response.status.isSuccess()) {
response.body<List<HorseApiDto>>().map { it.toDomain() }
} else emptyList()
}
override suspend fun getPferd(id: String): Result<Pferd> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.PFERDE}/$id")
if (response.status.isSuccess()) {
response.body<HorseApiDto>().toDomain()
} else throw Exception("Pferd nicht gefunden")
}
override suspend fun savePferd(pferd: Pferd): Result<Pferd> = runCatching {
val response = client.put("${ApiRoutes.Masterdata.PFERDE}/${pferd.id}") {
contentType(ContentType.Application.Json)
setBody(HorseApiDto(
pferdId = pferd.id,
pferdeName = pferd.name,
lebensnummer = pferd.lebensnummer,
geschlecht = "UNBEKANNT", // Fallback
geburtsjahr = pferd.geburtsjahr,
satznummer = pferd.oepsNummer
))
}
if (response.status.isSuccess()) {
response.body<HorseApiDto>().toDomain()
} else throw Exception("Fehler beim Speichern des Pferdes")
}
override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.FUNKTIONAERE}/search") {
parameter("q", query)
}
if (response.status.isSuccess()) {
response.body<List<FunktionaerApiDto>>().map {
Funktionaer(it.funktionaerId, it.name ?: "Unbekannt", it.qualifikationen, it.istAktiv)
}
} else emptyList()
}
override suspend fun listVereine(): Result<List<Verein>> = runCatching {
val response = client.get(ApiRoutes.Masterdata.VEREINE)
if (response.status.isSuccess()) {
response.body<List<VereinApiDto>>().map {
Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter)
}
} else emptyList()
}
override suspend fun getVereinById(id: String): Result<Verein> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.VEREINE}/$id")
if (response.status.isSuccess()) {
val it = response.body<VereinApiDto>()
Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter)
} else throw Exception("Verein nicht gefunden")
}
// Interne Hilfs-DTOs für das Mapping der Masterdata-API
@Serializable
private data class ReiterApiDto(
val reiterId: String,
val vorname: String,
val nachname: String,
val satznummer: String? = null,
val vereinsName: String? = null,
val feiId: String? = null,
val reiterLizenz: String? = null
) {
fun toDomain() = Reiter(
id = reiterId,
vorname = vorname,
nachname = nachname,
satznummer = satznummer,
verein = vereinsName,
feiId = feiId,
oepsNummer = satznummer
)
}
@Serializable
private data class HorseApiDto(
val pferdId: String,
val pferdeName: String,
val lebensnummer: String? = null,
val geschlecht: String,
val geburtsjahr: Int? = null,
val satznummer: String? = null
) {
fun toDomain() = Pferd(
id = pferdId,
name = pferdeName,
lebensnummer = lebensnummer ?: "",
geburtsjahr = geburtsjahr,
oepsNummer = satznummer
)
}
@Serializable
private data class FunktionaerApiDto(
val funktionaerId: String,
val name: String? = null,
val qualifikationen: List<String> = emptyList(),
val istAktiv: Boolean
)
@Serializable
private data class VereinApiDto(
val vereinId: String,
val vereinsNummer: String,
val name: String,
val ort: String? = null,
val istVeranstalter: Boolean
)
}
@@ -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<List<Nennung>> = runCatching {
val response = client.get("${ApiRoutes.Turniere.ROOT}/$turnierId/nennungen")
if (response.status.isSuccess()) {
response.body<List<NennungSummaryDto>>().map { it.toDomain() }
} else {
throw HttpError(response.status.value)
}
}
override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = runCatching {
val response = client.get(ApiRoutes.Bewerbe.nennungen(bewerbId))
if (response.status.isSuccess()) {
response.body<List<NennungSummaryDto>>().map { it.toDomain() }
} else {
throw HttpError(response.status.value)
}
}
override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> = runCatching {
val response = client.post(ApiRoutes.Bewerbe.nennungen(request.bewerbId.toLong())) {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
response.body<NennungDetailDto>().toDomain()
} else {
throw HttpError(response.status.value)
}
}
override suspend fun updateStatus(id: String, status: String): Result<Nennung> = runCatching {
val response = client.patch("${ApiRoutes.API_PREFIX}/nennungen/$id/status") {
contentType(ContentType.Application.Json)
setBody(mapOf("status" to status))
}
if (response.status.isSuccess()) {
response.body<NennungDetailDto>().toDomain()
} else {
throw HttpError(response.status.value)
}
}
override suspend fun delete(id: String): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.API_PREFIX}/nennungen/$id")
if (!response.status.isSuccess()) {
throw HttpError(response.status.value)
}
}
private fun NennungSummaryDto.toDomain() = Nennung(
id = nennungId,
turnierId = turnierId,
bewerbId = bewerbId,
abteilungId = abteilungId,
reiterId = reiterId,
pferdId = pferdId,
status = status,
istNachnennung = istNachnennung,
createdAt = createdAt
)
private fun NennungDetailDto.toDomain() = Nennung(
id = nennungId,
turnierId = turnierId,
bewerbId = bewerbId,
abteilungId = abteilungId,
reiterId = reiterId,
pferdId = pferdId,
status = status,
istNachnennung = istNachnennung,
createdAt = createdAt
)
}
@@ -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<List<Serie>> = runCatching {
client.get(ApiRoutes.Series.ROOT).body()
}
override suspend fun getById(id: String): Result<Serie> = runCatching {
client.get("${ApiRoutes.Series.ROOT}/$id").body()
}
override suspend fun save(serie: Serie): Result<Serie> = runCatching {
if (serie.id == null) {
client.post(ApiRoutes.Series.ROOT) {
contentType(ContentType.Application.Json)
setBody(serie)
}.body()
} else {
client.put("${ApiRoutes.Series.ROOT}/${serie.id}") {
contentType(ContentType.Application.Json)
setBody(serie)
}.body()
}
}
override suspend fun getStand(serieId: String): Result<List<SerieStandEntry>> = runCatching {
client.get(ApiRoutes.Series.stand(serieId)).body()
}
}
@@ -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<List<StartlistenZeile>> = runCatching {
val response = client.post("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/startliste/generate")
when {
response.status.isSuccess() -> response.body()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/startliste")
when {
response.status.isSuccess() -> response.body()
response.status == HttpStatusCode.NotFound -> emptyList()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
else -> throw HttpError(response.status.value)
}
}
}
@@ -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<List<Turnier>> = runCatching {
val response = client.get(ApiRoutes.Turniere.ROOT)
when {
response.status.isSuccess() -> response.body<List<TurnierDto>>().map { it.toDomain() }
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getById(id: Long): Result<Turnier> = runCatching {
val response = client.get("${ApiRoutes.Turniere.ROOT}/$id")
when {
response.status.isSuccess() -> response.body<TurnierDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun create(model: Turnier): Result<Turnier> = runCatching {
val response = client.post(ApiRoutes.Turniere.ROOT) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<TurnierDto>().toDomain()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun update(id: Long, model: Turnier): Result<Turnier> = runCatching {
val response = client.put("${ApiRoutes.Turniere.ROOT}/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<TurnierDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.Turniere.ROOT}/$id")
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
}
@@ -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
)
@@ -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<AbteilungsWarnungDto> = emptyList(),
)
@Serializable
data class AbteilungsWarnungDto(
val code: String,
val nachricht: String,
val oetoParagraph: String? = null,
)
@Serializable
data class AbteilungDto(
val id: Long,
val bewerbId: Long,
val name: String,
)
@@ -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<List<Abteilung>>
suspend fun getById(id: Long): Result<Abteilung>
suspend fun create(model: Abteilung): Result<Abteilung>
suspend fun update(id: Long, model: Abteilung): Result<Abteilung>
suspend fun delete(id: Long): Result<Unit>
}
@@ -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<AbteilungsWarnung> = emptyList(),
// Zeitplan-Felder
val geplantesDatum: String? = null, // ISO-Format
val beginnZeit: String? = null, // "HH:mm"
val reitdauerMinuten: Int? = null,
val umbauMinuten: Int? = null,
val besichtigungMinuten: Int? = null,
val austragungsplatzId: String? = null,
)
data class AbteilungsWarnung(
val code: String,
val nachricht: String,
val oetoParagraph: String?
)
data class AuditLogEntry(
val id: String,
val entityType: String,
val entityId: String,
val action: String,
val userId: String?,
val timestamp: String,
val changesJson: String?
)
interface BewerbRepository {
suspend fun list(turnierId: Long): Result<List<Bewerb>>
suspend fun getById(id: Long): Result<Bewerb>
suspend fun create(model: Bewerb): Result<Bewerb>
suspend fun update(id: Long, model: Bewerb): Result<Bewerb>
suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb>
suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>>
suspend fun exportZnsBSatz(turnierId: Long): Result<String>
suspend fun delete(id: Long): Result<Unit>
suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit>
}
@@ -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<List<Ergebnis>>
suspend fun save(ergebnis: Ergebnis): Result<Ergebnis>
suspend fun calculatePlatzierung(bewerbId: String): Result<List<Ergebnis>>
suspend fun exportPdf(bewerbId: String): Result<ByteArray>
}
@@ -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<String>,
val istAktiv: Boolean
)
data class Verein(
val id: String,
val name: String,
val vereinsNummer: String,
val ort: String?,
val istVeranstalter: Boolean
)
interface MasterdataRepository {
suspend fun searchReiter(query: String): Result<List<Reiter>>
suspend fun getReiter(id: String): Result<Reiter>
suspend fun saveReiter(reiter: Reiter): Result<Reiter>
suspend fun searchPferde(query: String): Result<List<Pferd>>
suspend fun getPferd(id: String): Result<Pferd>
suspend fun savePferd(pferd: Pferd): Result<Pferd>
suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>>
suspend fun listVereine(): Result<List<Verein>>
suspend fun getVereinById(id: String): Result<Verein>
}
@@ -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<List<Nennung>>
suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>>
suspend fun einreichen(request: at.mocode.turnier.feature.data.remote.dto.NennungEinreichenRequest): Result<Nennung>
suspend fun updateStatus(id: String, status: String): Result<Nennung>
suspend fun delete(id: String): Result<Unit>
}
@@ -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<String> = emptySet()
)
@Serializable
data class SerieStandEntry(
val reiterId: String,
val pferdId: String?,
val punkte: Double,
val anzahlWertungen: Int
)
@Serializable
data class SeriePunkt(
val id: String? = null,
val serieId: String,
val reiterId: String,
val pferdId: String,
val bewerbId: String,
val punkte: Double,
val platzierung: Int
)
interface SeriesRepository {
suspend fun getAll(): Result<List<Serie>>
suspend fun getById(id: String): Result<Serie>
suspend fun save(serie: Serie): Result<Serie>
suspend fun getStand(serieId: String): Result<List<SerieStandEntry>>
}
@@ -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<List<StartlistenZeile>>
suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>>
}
@@ -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<List<Turnier>>
suspend fun getById(id: Long): Result<Turnier>
suspend fun create(model: Turnier): Result<Turnier>
suspend fun update(id: Long, model: Turnier): Result<Turnier>
suspend fun delete(id: Long): Result<Unit>
}
@@ -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 = ""
)
@@ -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<BewerbRepository> { DefaultBewerbRepository(client = get(qualifier = named("apiClient"))) }
single<AbteilungRepository> { DefaultAbteilungRepository(client = get(qualifier = named("apiClient"))) }
single<StartlistenRepository> { DefaultStartlistenRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.turnier.feature.domain.NennungRepository> { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.turnier.feature.domain.MasterdataRepository> { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.turnier.feature.domain.ErgebnisRepository> { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.turnier.feature.domain.SeriesRepository> { DefaultSeriesRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.frontend.features.turnier.domain.NennungRepository> { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.frontend.features.turnier.domain.MasterdataRepository> { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.frontend.features.turnier.domain.ErgebnisRepository> { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.frontend.features.turnier.domain.SeriesRepository> { DefaultSeriesRepository(client = get(qualifier = named("apiClient"))) }
// ViewModels
factory { TurnierViewModel(repo = get()) }
@@ -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<StartlistenEintrag> = emptyList(),
val ergebnisse: List<ErgebnisEintrag> = emptyList(),
)
sealed interface AbteilungIntent {
data class LoadByBewerb(val bewerbId: Long, val abteilungsNr: Int) : AbteilungIntent
data object Refresh : AbteilungIntent
data class UpdateErgebnis(val startNr: Int, val punkte: Double?) : AbteilungIntent
data class ReorderStartliste(val fromIndex: Int, val toIndex: Int) : AbteilungIntent
data object Publish : AbteilungIntent
data object ClearError : AbteilungIntent
}
interface AbteilungRepository {
suspend fun loadStartliste(bewerbId: Long, abteilungsNr: Int): List<StartlistenEintrag>
suspend fun loadErgebnisse(bewerbId: Long, abteilungsNr: Int): List<ErgebnisEintrag>
suspend fun saveErgebnis(bewerbId: Long, abteilungsNr: Int, startNr: Int, punkte: Double?)
suspend fun saveStartlistenOrder(bewerbId: Long, abteilungsNr: Int, orderedStartNr: List<Int>)
suspend fun publish(bewerbId: Long, abteilungsNr: Int)
}
class AbteilungViewModel(
private val repo: AbteilungRepository,
private var bewerbId: Long,
private var abteilungsNr: Int,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(AbteilungState(isLoading = true))
val state: StateFlow<AbteilungState> = _state
init {
send(AbteilungIntent.LoadByBewerb(bewerbId, abteilungsNr))
}
fun send(intent: AbteilungIntent) {
when (intent) {
is AbteilungIntent.LoadByBewerb -> {
bewerbId = intent.bewerbId
abteilungsNr = intent.abteilungsNr
load()
}
is AbteilungIntent.Refresh -> load()
is AbteilungIntent.UpdateErgebnis -> updateErgebnis(intent.startNr, intent.punkte)
is AbteilungIntent.ReorderStartliste -> reorder(intent.fromIndex, intent.toIndex)
is AbteilungIntent.Publish -> publish()
is AbteilungIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val start = repo.loadStartliste(bewerbId, abteilungsNr)
val erg = repo.loadErgebnisse(bewerbId, abteilungsNr)
reduce { it.copy(isLoading = false, startliste = start, ergebnisse = erg) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
}
}
}
private fun updateErgebnis(startNr: Int, punkte: Double?) {
scope.launch {
try {
repo.saveErgebnis(bewerbId, abteilungsNr, startNr, punkte)
// Lokale Spiegelung
val newErg = state.value.ergebnisse.toMutableList()
val idx = newErg.indexOfFirst { it.startNr == startNr }
if (idx >= 0) newErg[idx] = newErg[idx].copy(punkte = punkte) else newErg += ErgebnisEintrag(startNr, punkte, null)
reduce { it.copy(ergebnisse = newErg) }
} catch (t: Throwable) {
reduce { it.copy(errorMessage = t.message ?: "Fehler beim Speichern") }
}
}
}
private fun reorder(fromIndex: Int, toIndex: Int) {
val list = state.value.startliste.toMutableList()
if (fromIndex in list.indices && toIndex in list.indices) {
val item = list.removeAt(fromIndex)
list.add(toIndex, item)
reduce { it.copy(startliste = list) }
scope.launch {
try {
repo.saveStartlistenOrder(bewerbId, abteilungsNr, list.map { it.startNr })
} catch (t: Throwable) {
reduce { it.copy(errorMessage = t.message ?: "Fehler beim Speichern der Reihenfolge") }
}
}
}
}
private fun publish() {
scope.launch {
try {
repo.publish(bewerbId, abteilungsNr)
} catch (t: Throwable) {
reduce { it.copy(errorMessage = t.message ?: "Fehler beim Veröffentlichen") }
}
}
}
private inline fun reduce(block: (AbteilungState) -> AbteilungState) {
_state.value = block(_state.value)
}
}
@@ -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+).",
)
}
}
@@ -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<AbteilungsInput> = emptyList(),
)
sealed interface BewerbAnlegenIntent {
data object Open : BewerbAnlegenIntent
data object Close : BewerbAnlegenIntent
data class SetBewerbsTyp(val typ: String) : BewerbAnlegenIntent
data class SetAbteilungsTyp(val typ: AbteilungsTyp) : BewerbAnlegenIntent
data object ApplyAutoSuggestionIfNeeded : BewerbAnlegenIntent
}
class BewerbAnlegenViewModel {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(BewerbAnlegenState())
val state: StateFlow<BewerbAnlegenState> = _state
fun send(intent: BewerbAnlegenIntent) {
when (intent) {
is BewerbAnlegenIntent.Open -> reduce { it.copy(isOpen = true) }
is BewerbAnlegenIntent.Close -> reduce { BewerbAnlegenState() }
is BewerbAnlegenIntent.SetBewerbsTyp -> reduce { it.copy(bewerbsTyp = intent.typ) }.also {
// Bei Änderung des Typs gleich prüfen, ob Auto-Vorschlag anzuwenden ist
send(BewerbAnlegenIntent.ApplyAutoSuggestionIfNeeded)
}
is BewerbAnlegenIntent.SetAbteilungsTyp -> reduce { it.copy(abteilungsTyp = intent.typ) }
is BewerbAnlegenIntent.ApplyAutoSuggestionIfNeeded -> applySuggestion()
}
}
private fun applySuggestion() {
val s = _state.value
val bTyp = s.bewerbsTyp.uppercase()
val suggestion = when {
bTyp.contains("CSN-C-NEU") -> listOf(
AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1),
AbteilungsInput(2, label = "Abteilung 2: R2+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS),
)
bTyp.contains("CDN-B") || bTyp.contains("CDNP-B") -> listOf(
AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1),
AbteilungsInput(2, label = "Abteilung 2: R2", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS),
AbteilungsInput(3, label = "Abteilung 3: R3+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS),
)
bTyp.contains("CSN-B") -> listOf(
AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1),
AbteilungsInput(2, label = "Abteilung 2: R2+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS),
)
else -> emptyList()
}
if (suggestion.isNotEmpty()) {
reduce { it.copy(abteilungen = suggestion, abteilungsTyp = AbteilungsTyp.SEPARATE_SIEGEREHRUNG) }
}
}
private inline fun reduce(block: (BewerbAnlegenState) -> BewerbAnlegenState) {
_state.value = block(_state.value)
}
}
@@ -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<BewerbListItem> = emptyList(),
val filtered: List<BewerbListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
val importPreview: List<ZnsBewerb> = emptyList(),
val nennungenPreview: List<ZnsNennung> = emptyList(),
val showImportDialog: Boolean = false,
val showStartlistePreview: Boolean = false,
val currentStartliste: List<StartlistenZeile> = emptyList(),
val discoveredNodes: List<DiscoveredService> = emptyList(),
val isScanning: Boolean = false,
// Zeitplan-Audit
val auditLog: List<at.mocode.turnier.feature.domain.AuditLogEntry> = emptyList(),
val isAuditLoading: Boolean = false,
val exportContent: String? = null,
val showExportDialog: Boolean = false,
val ergebnisse: List<at.mocode.turnier.feature.domain.Ergebnis> = 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<String>) : BewerbIntent
data class ConfirmImport(val turnierId: Long) : BewerbIntent
data object GenerateStartliste : BewerbIntent
data object CloseStartlistePreview : BewerbIntent
data object StartNetworkScan : BewerbIntent
data object StopNetworkScan : BewerbIntent
data object RefreshDiscoveredNodes : BewerbIntent
data class UpdateZeitplan(val id: Long, val beginnZeit: String?) : BewerbIntent
data class LoadAuditLog(val bewerbId: Long) : BewerbIntent
data object ExportZnsBSatz : BewerbIntent
data object CloseExportDialog : BewerbIntent
data object LoadErgebnisse : BewerbIntent
data class OpenErgebnisEdit(val zeile: StartlistenZeile) : BewerbIntent
data object CloseErgebnisEdit : BewerbIntent
data class SaveErgebnis(val ergebnis: at.mocode.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<BewerbState> = _state
// Interne Instanz des DialogVM (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<BewerbListItem>, query: String): List<BewerbListItem> {
if (query.isBlank()) return list
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.sparte.contains(q, ignoreCase = true) ||
it.klasse.contains(q, ignoreCase = true) ||
it.tag.contains(q, ignoreCase = true)
}
}
private fun syncDialogState() {
_state.value = _state.value.copy(dialogState = dialogVm.state.value)
}
private inline fun reduce(block: (BewerbState) -> BewerbState) {
_state.value = block(_state.value)
}
}
@@ -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<RichterEinsatzDto> = emptyList(),
val teilungsTyp: String = "", // Hinweis: aktuell nur UI; Backend-Feld folgt separat
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateBewerbWizardScreen(
modifier: Modifier = Modifier,
state: CreateBewerbWizardState,
onStateChange: (CreateBewerbWizardState) -> Unit,
onSubmit: (CreateBewerbPayload) -> Unit,
) {
var selectedTab by remember { mutableStateOf(0) }
val steps = WizardStep.entries.toTypedArray()
Column(modifier.fillMaxSize().padding(16.dp)) {
Text("Neuen Bewerb anlegen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
SecondaryTabRow(
selectedTabIndex = selectedTab,
modifier = Modifier,
divider = { HorizontalDivider() }
) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Identifikation") })
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }, text = { Text("Details & Finanzen") })
Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }, text = { Text("Ort & Zeitplan") })
Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }, text = { Text("Richter & Teilung") })
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
when (steps[selectedTab]) {
WizardStep.IDENTIFIKATION -> StepIdentifikation(state, onStateChange)
WizardStep.DETAILS_FINANZEN -> StepDetailsFinanzen(state, onStateChange)
WizardStep.ORT_ZEIT -> StepOrtZeit(state, onStateChange)
WizardStep.RICHTER_TEILUNG -> StepRichterTeilung(state, onStateChange)
}
Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(enabled = selectedTab > 0, onClick = { selectedTab-- }) { Text("Zurück") }
Spacer(Modifier.weight(1f))
if (selectedTab < steps.lastIndex) {
TextButton(onClick = { selectedTab++ }) { Text("Weiter") }
} else {
OutlinedButton(onClick = {
val payload = state.toPayloadOrNull()
if (payload != null) onSubmit(payload)
}) { Text("Bewerb anlegen") }
}
}
}
}
@Composable
private fun StepIdentifikation(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
OutlinedTextField(
value = state.klasse,
onValueChange = { onStateChange(state.copy(klasse = it)) },
label = { Text("Sparte/Kategorie/Klasse") },
modifier = Modifier.fillMaxWidth()
)
}
item {
OutlinedTextField(
value = state.hoeheCm,
onValueChange = { onStateChange(state.copy(hoeheCm = it.filter { ch -> ch.isDigit() })) },
label = { Text("Höhe (cm)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
OutlinedTextField(
value = state.bezeichnung,
onValueChange = { onStateChange(state.copy(bezeichnung = it)) },
label = { Text("Bezeichnung") },
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun StepDetailsFinanzen(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
OutlinedTextField(
value = state.beschreibung,
onValueChange = { onStateChange(state.copy(beschreibung = it)) },
label = { Text("Beschreibung (optional)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
OutlinedTextField(
value = state.aufgabe,
onValueChange = { onStateChange(state.copy(aufgabe = it)) },
label = { Text("Aufgabe (z.B. R1)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
OutlinedTextField(
value = state.startgeld,
onValueChange = { onStateChange(state.copy(startgeld = it.filter { ch -> ch.isDigit() })) },
label = { Text("Startgeld (Cent)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = state.geldpreisAusbezahlt, onCheckedChange = { onStateChange(state.copy(geldpreisAusbezahlt = it)) })
Text("Geldpreis ausbezahlt")
}
}
}
}
@Composable
private fun StepOrtZeit(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
OutlinedTextField(
value = state.austragungsplatzId,
onValueChange = { onStateChange(state.copy(austragungsplatzId = it)) },
label = { Text("Austragungsplatz-ID (optional)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
OutlinedTextField(
value = state.beginnZeitTyp,
onValueChange = { onStateChange(state.copy(beginnZeitTyp = it)) },
label = { Text("Beginn (FIX/ANSCHLIESSEND)") },
modifier = Modifier.fillMaxWidth()
)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = state.geplantesDatum,
onValueChange = { onStateChange(state.copy(geplantesDatum = it)) },
label = { Text("Datum (yyyy-MM-dd)") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.beginnZeit,
onValueChange = { onStateChange(state.copy(beginnZeit = it)) },
label = { Text("Beginn (HH:mm)") },
modifier = Modifier.weight(1f)
)
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = state.reitdauerMinuten,
onValueChange = { onStateChange(state.copy(reitdauerMinuten = it.filter { ch -> ch.isDigit() })) },
label = { Text("Reitdauer (min)") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.umbauMinuten,
onValueChange = { onStateChange(state.copy(umbauMinuten = it.filter { ch -> ch.isDigit() })) },
label = { Text("Umbau (min)") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.besichtigungMinuten,
onValueChange = { onStateChange(state.copy(besichtigungMinuten = it.filter { ch -> ch.isDigit() })) },
label = { Text("Besichtigung (min)") },
modifier = Modifier.weight(1f)
)
}
}
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = state.stechenGeplant, onCheckedChange = { onStateChange(state.copy(stechenGeplant = it)) })
Text("Stechen geplant")
}
}
}
}
@Composable
private fun StepRichterTeilung(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
Column(Modifier.fillMaxWidth()) {
// Warn-Logik (mock): Wenn Richter ausgewählt und Position = "C" ohne weiterer Prüfung -> TB-Hinweis
val warnTb = state.richter.isNotEmpty()
if (warnTb) {
Box(
Modifier.fillMaxWidth().background(Color(0xFFFFF8E1)).padding(12.dp)
) { Text("Hinweis: Richter-Zuweisung erfordert Freigabe durch TB (Qualifikation prüfen)", color = Color(0xFFFFA000)) }
Spacer(Modifier.height(8.dp))
}
OutlinedTextField(
value = state.teilungsTyp,
onValueChange = { onStateChange(state.copy(teilungsTyp = it)) },
label = { Text("Teilungsregel (z.B. MANUELL)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
// Minimal-UI für das Hinzufügen eines Richters (freie Eingabe von UUID + Position)
var funktionaerId by remember { mutableStateOf("") }
var position by remember { mutableStateOf("") }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(funktionaerId, { funktionaerId = it }, label = { Text("Funktionär-ID") }, modifier = Modifier.weight(1f))
OutlinedTextField(position, { position = it }, label = { Text("Position (C/M/…)") }, modifier = Modifier.weight(1f))
TextButton(onClick = {
if (funktionaerId.isNotBlank() && position.isNotBlank()) {
val list = state.richter + RichterEinsatzDto(funktionaerId = funktionaerId.trim(), position = position.trim())
onStateChange(state.copy(richter = list))
funktionaerId = ""; position = ""
}
}) { Text("Hinzufügen") }
}
Spacer(Modifier.height(8.dp))
state.richter.forEachIndexed { idx, r ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${idx + 1}. ${r.position} ${r.funktionaerId}")
TextButton(onClick = {
val list = state.richter.toMutableList().also { it.removeAt(idx) }
onStateChange(state.copy(richter = list))
}) { Text("Entfernen") }
}
}
}
}
// --- Mapping UI-State -> API-Payload ---
private fun CreateBewerbWizardState.toPayloadOrNull(): CreateBewerbPayload? {
if (klasse.isBlank() || bezeichnung.isBlank()) return null
val hoehe: Int? = hoeheCm.toIntOrNull()
val startgeldCent: Long? = startgeld.toLongOrNull()
val datum: LocalDate? = runCatching { if (geplantesDatum.isBlank()) null else LocalDate.parse(geplantesDatum) }.getOrNull()
val zeit: LocalTime? = runCatching { if (beginnZeit.isBlank()) null else LocalTime.parse(beginnZeit) }.getOrNull()
val beginnTyp: String? = beginnZeitTyp.ifBlank { null }
val reitMin = reitdauerMinuten.toIntOrNull()
val umbauMin = umbauMinuten.toIntOrNull()
val besMin = besichtigungMinuten.toIntOrNull()
return CreateBewerbPayload(
klasse = klasse.trim(),
hoeheCm = hoehe,
bezeichnung = bezeichnung.trim(),
beschreibung = beschreibung.ifBlank { null },
aufgabe = aufgabe.ifBlank { null },
aufgabenNummer = null,
paraGrade = null,
austragungsplatzId = austragungsplatzId.ifBlank { null },
richterEinsaetze = richter,
geplantesDatum = datum,
beginnZeitTyp = beginnTyp,
beginnZeit = zeit,
reitdauerMinuten = reitMin,
umbauMinuten = umbauMin,
besichtigungMinuten = besMin,
stechenGeplant = stechenGeplant,
startgeldCent = startgeldCent,
geldpreisAusbezahlt = geldpreisAusbezahlt,
)
}
@@ -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")
}
}
)
}
@@ -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")
}
}
}
}
@@ -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<Serie> = emptyList(),
val isLoading: Boolean = false,
val selectedSerieStand: List<SerieStandEntry> = emptyList(),
val error: String? = null
)
class SeriesViewModel(
private val repository: SeriesRepository
) : ViewModel() {
private val _state = MutableStateFlow(SeriesState())
val state = _state.asStateFlow()
init {
loadSeries()
}
fun loadSeries() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
repository.getAll()
.onSuccess { series ->
_state.value = _state.value.copy(series = series, isLoading = false)
}
.onFailure {
_state.value = _state.value.copy(error = it.message, isLoading = false)
}
}
}
fun selectSerie(id: String) {
viewModelScope.launch {
repository.getStand(id)
.onSuccess { stand ->
_state.value = _state.value.copy(selectedSerieStand = stand)
}
}
}
fun createSerie(name: String) {
viewModelScope.launch {
repository.save(Serie(name = name))
.onSuccess {
loadSeries()
}
}
}
}
@@ -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<BuchungspositionUiModel>) {
val gesamtSoll = buchungen.sumOf { it.soll }
val gesamtHaben = buchungen.sumOf { it.haben }
val gesamtSaldo = gesamtSoll - gesamtHaben
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("Aktualisieren", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Text("Übersicht", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Text("Tabelle Leeren", fontSize = 12.sp, color = Color(0xFFEA580C))
}
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Text("Pferd aus Liste entfernen", fontSize = 12.sp)
}
}
// Tabellen-Header
Row(
modifier = Modifier.fillMaxWidth().background(Color(0xFFF3F4F6)).padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text("Buchungstext", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f))
Text("Soll", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Haben", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Saldo", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Buchen", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
Text("Rechnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
}
HorizontalDivider()
LazyColumn(modifier = Modifier.weight(1f)) {
items(buchungen) { b ->
val saldo = b.soll - b.haben
var buchen by remember { mutableStateOf(false) }
var rechnung by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(b.buchungstext, fontSize = 13.sp, modifier = Modifier.weight(3f))
Text("%.2f €".format(b.soll), fontSize = 13.sp, modifier = Modifier.weight(1f))
Text("%.2f €".format(b.haben), fontSize = 13.sp, modifier = Modifier.weight(1f))
Text(
"%.2f €".format(saldo),
fontSize = 13.sp,
color = if (saldo > 0) OffenePostenRot else Color.Unspecified,
fontWeight = if (saldo > 0) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier.weight(1f),
)
Checkbox(checked = buchen, onCheckedChange = { buchen = it }, modifier = Modifier.width(60.dp))
Checkbox(checked = rechnung, onCheckedChange = { rechnung = it }, modifier = Modifier.width(70.dp))
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
// Gesamt-Zeile
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("GESAMT", fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(3f))
Text("%.2f €".format(gesamtSoll), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text("%.2f €".format(gesamtHaben), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text(
"%.2f €".format(gesamtSaldo),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = if (gesamtSaldo > 0) OffenePostenRot else Color.Unspecified,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun BuchungenSidebar() {
var suchtext by remember { mutableStateOf("") }
var zahlungsart by remember { mutableStateOf("BAR") }
Column(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Nach Reiter oder Pferd", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = suchtext,
onValueChange = { suchtext = it },
placeholder = { Text("Bitte auswählen...", fontSize = 12.sp) },
modifier = Modifier.fillMaxWidth().height(44.dp),
singleLine = true,
)
HorizontalDivider()
Text("Buchen:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("0.00 €", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Button(
onClick = {},
enabled = false,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Buchen") }
}
HorizontalDivider()
Text("Direkt Drucken:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) {
Icon(Icons.Default.Print, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("Saldo", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) {
Text("Rechnung", fontSize = 12.sp)
}
}
HorizontalDivider()
Text("Zahlungsart:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
listOf("BAR", "Scheck (+30 €)", "Bankomat", "Kreditkarte").forEach { art ->
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = zahlungsart == art, onClick = { zahlungsart = art })
Text(art, fontSize = 13.sp)
}
}
Button(
onClick = {},
enabled = false,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Gebühr buchen") }
// Hinweis
Surface(
color = Color(0xFFEFF6FF),
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)),
) {
Text(
"💡 Hinweis: Bei Barzahlung werden die Buchungen sofort verarbeitet. Scheck-Zahlungen erfordern eine zusätzliche Gebühr von 30 €.",
fontSize = 11.sp,
color = Color(0xFF1E40AF),
modifier = Modifier.padding(8.dp),
)
}
}
}
@Composable
private fun OffenePostenContent() {
PlaceholderContent(title = "Offene Posten", subtitle = "Alle offenen Forderungen …")
}
@Composable
private fun RechnungContent() {
PlaceholderContent(title = "Rechnung", subtitle = "Rechnungserstellung …")
}
// --- UI-Modelle ---
data class BuchungspositionUiModel(val buchungstext: String, val soll: Double, val haben: Double)
@@ -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)
@@ -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<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
OrganisationTabContent(viewModel = nennungViewModel)
}
2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId)
3 -> ArtikelTabContent()
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
5 -> {
val nennungViewModel = koinInject<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
NennungenTabContent(
viewModel = nennungViewModel,
onAbrechnungClick = { selectedTab = 4 }
)
}
6 -> {
val nennungViewModel = koinInject<TurnierNennungViewModel>(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()
@@ -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<BewerbListItem>,
selectedId: Long?,
onSelect: (Long?) -> Unit,
ergebnisse: List<Ergebnis>,
startliste: List<StartlistenZeile>,
onCalculate: () -> Unit,
onPrint: () -> Unit
) {
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
PrimaryScrollableTabRow(
selectedTabIndex = selectedIndex,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = ElBlue,
edgePadding = 0.dp,
) {
bewerbe.forEachIndexed { index, bewerb ->
Tab(
selected = selectedId == bewerb.id,
onClick = { onSelect(bewerb.id) },
text = { Text(bewerb.tag, fontSize = 12.sp) },
)
}
}
HorizontalDivider()
val selectedBewerb = bewerbe.getOrNull(selectedIndex)
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = selectedBewerb?.let { "${it.tag} ${it.name}" } ?: "Kein Bewerb ausgewählt",
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
)
Spacer(Modifier.weight(1f))
OutlinedButton(
onClick = onCalculate,
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Platzierung berechnen", fontSize = 12.sp)
}
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Exportieren", fontSize = 12.sp)
}
OutlinedButton(
onClick = onPrint,
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Drucken", fontSize = 12.sp)
}
}
// Tabellen-Header
Row(
modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp))
Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(65.dp))
Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Fehler", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
Text("Zeit", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
Text("Punkte", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
}
HorizontalDivider()
if (ergebnisse.isEmpty()) {
// Leere Liste
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine Ergebnisse vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(8.dp))
Text("Ergebnisse werden nach dem Turnier eingetragen.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {}) {
Text("Ergebnisse importieren", fontSize = 13.sp)
}
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
) {
Text("Ergebnisse eingeben", fontSize = 13.sp)
}
}
}
}
} else {
androidx.compose.foundation.lazy.LazyColumn(modifier = Modifier.fillMaxSize()) {
items(ergebnisse.size) { index ->
val erg = ergebnisse[index]
val zeile = startliste.find { it.nennungId == erg.nennungId }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(erg.platzierung?.let { "$it." } ?: "-", fontSize = 12.sp, modifier = Modifier.width(50.dp), fontWeight = FontWeight.Bold, color = ElBlue)
Text(zeile?.nr?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(65.dp))
Text(zeile?.pferd ?: "Unbekannt", fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(zeile?.reiter ?: "Unbekannt", fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(erg.fehler?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(60.dp))
Text(erg.zeit?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(70.dp))
Text(erg.wertnote?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(70.dp))
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
}
}
@Composable
private fun PlatzierungGeldpreisPanel(
modifier: Modifier = Modifier,
ergebnisse: List<Ergebnis> = emptyList()
) {
val platzierteCount = ergebnisse.count { it.platzierung != null }
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Platzierung & Geldpreis", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
HorizontalDivider()
// Anzahl Platzierte
Text("Platzierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Anzahl Platzierte:", fontSize = 12.sp, modifier = Modifier.width(140.dp))
OutlinedTextField(
value = platzierteCount.toString(),
onValueChange = {},
modifier = Modifier.width(60.dp),
readOnly = true,
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Stechen ab Platz:", fontSize = 12.sp, modifier = Modifier.width(140.dp))
OutlinedTextField(
value = "",
onValueChange = {},
modifier = Modifier.width(60.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
}
HorizontalDivider()
// Geldpreise
Text("Geldpreise", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
Row(
modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp))
Text("Betrag (€)", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
}
HorizontalDivider()
listOf(1 to "", 2 to "", 3 to "").forEach { (platz, betrag) ->
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"$platz.",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = ElBlue,
modifier = Modifier.width(50.dp)
)
OutlinedTextField(
value = betrag,
onValueChange = {},
modifier = Modifier.weight(1f).height(36.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
HorizontalDivider()
// Aktions-Buttons
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
modifier = Modifier.fillMaxWidth(),
) {
Text("Platzierung berechnen", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) {
Text("Ergebnisliste drucken", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) {
Text("Geldpreise auszahlen", fontSize = 12.sp)
}
}
}
}
@@ -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<OnlineNennung> = emptyList(),
val isOnlineLoading: Boolean = false
)
data class NennungenState(
val isLoading: Boolean = false,
val nennungen: List<Nennung> = emptyList(),
val searchResultsReiter: List<Reiter> = emptyList(),
val searchResultsPferde: List<Pferd> = emptyList(),
val searchResultsFunktionaere: List<Funktionaer> = emptyList(),
val selectedReiter: Reiter? = null,
val selectedPferd: Pferd? = null,
val errorMessage: String? = null
)
class TurnierNennungViewModel(
private val nennungRepo: NennungRepository,
private val masterdataRepo: MasterdataRepository,
private val turnierId: Long
) {
// UI-State für den Online-Eingang Tab
val uiState = MutableStateFlow(TurnierOnlineUiState())
fun loadOnlineNennungen() {
uiState.value = uiState.value.copy(isOnlineLoading = true)
scope.launch {
// Mock-Laden
kotlinx.coroutines.delay(500)
uiState.value = uiState.value.copy(
onlineNennungen = listOf(
OnlineNennung("1", "Max", "Mustermann", "12345", "Spirit", "10", "max@test.at", "1, 2, 5")
),
isOnlineLoading = false
)
}
}
fun uebernehmeOnlineNennung(nennung: OnlineNennung) {
// Logik zur Übernahme
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(NennungenState())
val state: StateFlow<NennungenState> = _state
init {
loadNennungen()
}
fun loadNennungen() {
_state.value = _state.value.copy(isLoading = true)
scope.launch {
nennungRepo.list(turnierId).onSuccess { list ->
_state.value = _state.value.copy(nennungen = list, isLoading = false)
}.onFailure {
_state.value = _state.value.copy(errorMessage = "Fehler beim Laden: ${it.message}", isLoading = false)
}
}
}
fun searchReiter(query: String) {
if (query.length < 2) {
_state.value = _state.value.copy(searchResultsReiter = emptyList())
return
}
scope.launch {
masterdataRepo.searchReiter(query).onSuccess { list ->
_state.value = _state.value.copy(searchResultsReiter = list)
}
}
}
fun selectReiter(reiter: Reiter?) {
_state.value = _state.value.copy(selectedReiter = reiter)
}
fun saveReiter(reiter: Reiter) {
scope.launch {
masterdataRepo.saveReiter(reiter).onSuccess {
_state.value = _state.value.copy(selectedReiter = null)
}
}
}
fun searchPferde(query: String) {
if (query.length < 2) {
_state.value = _state.value.copy(searchResultsPferde = emptyList())
return
}
scope.launch {
masterdataRepo.searchPferde(query).onSuccess { list ->
_state.value = _state.value.copy(searchResultsPferde = list)
}
}
}
fun selectPferd(pferd: Pferd?) {
_state.value = _state.value.copy(selectedPferd = pferd)
}
fun savePferd(pferd: Pferd) {
scope.launch {
masterdataRepo.savePferd(pferd).onSuccess {
_state.value = _state.value.copy(selectedPferd = null)
}
}
}
fun searchFunktionaere(query: String) {
if (query.length < 2) {
_state.value = _state.value.copy(searchResultsFunktionaere = emptyList())
return
}
scope.launch {
masterdataRepo.searchFunktionaere(query).onSuccess { list ->
_state.value = _state.value.copy(searchResultsFunktionaere = list)
}
}
}
fun einreichen(bewerbId: String, abteilungId: String, reiterId: String, pferdId: String) {
_state.value = _state.value.copy(isLoading = true)
scope.launch {
val request = NennungEinreichenRequest(
abteilungId = abteilungId,
bewerbId = bewerbId,
turnierId = turnierId.toString(),
reiterId = reiterId,
pferdId = pferdId
)
nennungRepo.einreichen(request).onSuccess {
loadNennungen()
}.onFailure {
_state.value = _state.value.copy(errorMessage = "Fehler beim Einreichen: ${it.message}", isLoading = false)
}
}
}
}
@@ -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,
)
@@ -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 …")
}
}
}
}
@@ -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")
}
}
}
}
}
}
@@ -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)
@@ -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<String?>(null) }
var znsImportedAt by remember { mutableStateOf<String?>(null) }
val znsImportHistory =
remember { mutableStateListOf<Triple<String, String, Boolean>>() } // (source, payloadVersion, ok)
var typ by remember { mutableStateOf("ÖTO (National)") }
val sparten = remember { mutableStateListOf<String>() }
val klassen = remember { mutableStateListOf<String>() }
val kat = remember { mutableStateListOf<String>() }
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<String>
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<String>() }
var showZnsDialog by remember { mutableStateOf(false) }
var showZnsLog by remember { mutableStateOf(false) }
// Hilf's-States für DatePicker
var showDatePickerVon by remember { mutableStateOf(false) }
var showDatePickerBis by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
// ── Turnier-Konfiguration (Schritt 1 Logik) ───────────────────────────
SectionCard(title = "Turnier-Konfiguration & ZNS") {
FormRow("Turnier-Nr.:") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = turnierNr,
onValueChange = { if (it.length <= 5 && it.all { c -> c.isDigit() }) turnierNr = it },
placeholder = { Text("5-stellig", fontSize = 13.sp) },
modifier = Modifier.width(120.dp),
singleLine = true,
enabled = !nrConfirmed
)
when {
!nrConfirmed -> {
Button(
onClick = { showNrConfirm = true },
enabled = turnierNr.length == 5,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
) {
Text("Bestätigen")
}
}
else -> {
InputChip(
selected = true,
onClick = { },
label = { Text("Bestätigt") },
trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
)
}
}
}
when (turnierNr.length) {
5 if !nrConfirmed -> {
Text(
"Bitte Turnier-Nummer bestätigen um fortzufahren.",
color = MaterialTheme.colorScheme.error,
fontSize = 11.sp
)
}
}
}
FormRow("Typ:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
FilterChip(
selected = typ == "ÖTO (National)",
onClick = { typ = "ÖTO (National)" },
enabled = nrConfirmed,
label = { Text("ÖTO (National)") }
)
FilterChip(
selected = typ == "FEI (International)",
onClick = { typ = "FEI (International)" },
enabled = nrConfirmed,
label = { Text("FEI (International)") }
)
}
}
FormRow("ZNS-Stammdaten:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = { showZnsDialog = true },
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue), enabled = nrConfirmed
) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import via Internet")
}
OutlinedButton(onClick = { showZnsDialog = true }, enabled = nrConfirmed) {
Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import via USB")
}
TextButton(onClick = { showZnsLog = true }, enabled = nrConfirmed) { Text("Import-Log anzeigen…") }
}
val znsStatusColor = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = znsStatusColor,
modifier = Modifier.size(16.dp)
)
Text(
if (znsDataLoaded) "ZNS-Daten geladen" else "Keine ZNS-Daten geladen",
color = znsStatusColor,
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
if (znsDataLoaded) {
Spacer(Modifier.width(8.dp))
Text(
listOfNotNull(
znsPayloadVersion?.let { "Version: $it" },
znsImportedAt?.let { "Zeit: $it" },
).joinToString(""),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp
)
}
}
}
}
// ── Sparten & Kategorien (Schritt 2 Logik) ───────────────────────────
SectionCard(title = "Reglement & Sparten") {
FormRow("Sparte:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = sparten.contains("Dressur"),
onClick = { if (sparten.contains("Dressur")) sparten.remove("Dressur") else sparten.add("Dressur") },
enabled = nrConfirmed,
label = { Text("Dressur") }
)
FilterChip(
selected = sparten.contains("Springen"),
onClick = { if (sparten.contains("Springen")) sparten.remove("Springen") else sparten.add("Springen") },
enabled = nrConfirmed,
label = { Text("Springen") }
)
}
}
FormRow("Klasse:") {
val klassenListe = listOf("C-NEU", "C", "B", "A")
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
klassenListe.forEach { k ->
FilterChip(
selected = klassen.contains(k),
onClick = { if (klassen.contains(k)) klassen.remove(k) else klassen.add(k) },
enabled = nrConfirmed,
label = { Text(k) }
)
}
}
}
FormRow("Kategorien:") {
// Logik zur Generierung der Kategorien
val suggested = mutableListOf<String>()
sparten.forEach { s ->
val prefix = if (s == "Dressur") "CDN" else "CSN"
klassen.forEach { k ->
suggested.add("$prefix-$k")
suggested.add("${prefix}P-$k") // Pony Variante
}
}
when {
suggested.isEmpty() -> {
Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp)
}
else -> {
// Gruppiere nach Sparte (CDN/CSN)
val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" }
grouped.forEach { (gruppe, eintraege) ->
Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
Spacer(Modifier.height(4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
eintraege.sorted().forEach { c ->
InputChip(
selected = kat.contains(c),
onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) },
enabled = nrConfirmed,
label = { Text(c) }
)
}
}
Spacer(Modifier.height(8.dp))
}
}
}
}
FormRow("Zeitraum:") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
val vonMod =
if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp)
OutlinedTextField(
value = von,
onValueChange = {},
label = { Text("Von") },
modifier = vonMod,
readOnly = true,
enabled = nrConfirmed,
trailingIcon = { Icon(Icons.Default.DateRange, null) }
)
Text("bis")
val bisMod =
if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp)
OutlinedTextField(
value = bis,
onValueChange = {},
label = { Text("Bis") },
modifier = bisMod,
readOnly = true,
enabled = nrConfirmed,
trailingIcon = { Icon(Icons.Default.DateRange, null) }
)
}
val rangeText =
if (eventVon != null && eventBis != null) "Muss zwischen $eventVon $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen."
Text(rangeText, fontSize = 11.sp, color = Color.Gray)
}
}
// ── Branding (Schritt 3 Logik) ───────────────────────────────────────
SectionCard(title = "Turnier-Branding") {
// Default-Titel-Vorschlag: [Kategorien] [Verein-Ort] [Bundesland]
val defaultTitle = remember(kat.size, veranstalterOrt, veranstalterBundesland) {
val cats = if (kat.isEmpty()) "" else kat.sorted().joinToString(" ")
listOfNotNull(
cats.ifBlank { null },
listOfNotNull(veranstalterOrt, veranstalterBundesland).filter { it.isNotBlank() }.joinToString(" ")
.takeIf { it.isNotBlank() }
).joinToString(" ")
}
OutlinedTextField(
value = titel,
onValueChange = { titel = it },
label = { Text("Titel") },
placeholder = { if (defaultTitle.isNotBlank()) Text(defaultTitle) },
enabled = nrConfirmed,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = subTitel,
onValueChange = { subTitel = it },
label = { Text("Sub-Titel") },
enabled = nrConfirmed,
modifier = Modifier.fillMaxWidth()
)
// Ort im Branding-Bereich platzieren (mit Soft-Warnung bei Abweichung zum Veranstaltungsort)
FormRow("Ort:") {
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Austragungsort") },
enabled = nrConfirmed,
modifier = Modifier.fillMaxWidth(),
supportingText = {
if (eventOrt != null && ort.isNotBlank() && ort.trim() != eventOrt.trim()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = Color(0xFFF59E0B),
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(4.dp))
Text("Abweichung zum Veranstaltungsort ($eventOrt) bitte prüfen.", color = Color(0xFFF59E0B))
}
} else {
Text("Muss mit Veranstaltungsort übereinstimmen.")
}
}
)
}
// Turnier-Logo mit Fallback auf Veranstalterlogo
OutlinedTextField(
value = turnierLogoUrl,
onValueChange = { turnierLogoUrl = it },
label = { Text("Turnier-Logo (URL/Pfad)") },
enabled = nrConfirmed,
supportingText = {
Text("Wenn leer: verwende Veranstalter-Logo${if (!veranstalterLogoUrl.isNullOrBlank()) " ($veranstalterLogoUrl)" else ""}.")
},
modifier = Modifier.fillMaxWidth()
)
FormRow("Sponsoren:") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
sponsoren.forEach { s ->
InputChip(
selected = true,
onClick = { sponsoren.remove(s) },
enabled = nrConfirmed,
label = { Text(s) },
trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(14.dp)) }
)
}
TextButton(onClick = { sponsoren.add("Neuer Sponsor") }, enabled = nrConfirmed) {
Text("+ Hinzufügen")
}
}
}
}
// ── Footer ──────────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Save-Enable-Matrix (kleine Checkliste)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
AssistChip(onClick = {}, label = { Text("Nr bestätigt") }, leadingIcon = {
Icon(
if (nrConfirmed) Icons.Default.Check else Icons.Default.Close,
null,
tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
)
})
AssistChip(onClick = {}, label = { Text("ZNS geladen") }, leadingIcon = {
Icon(
if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close,
null,
tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
)
})
val dateOk = remember(von, bis, eventVon, eventBis) {
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()
}
}
}
@@ -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<Bewerb>,
selectedId: Long?,
onSelect: (Long?) -> Unit,
currentStartliste: List<StartlistenZeile>,
onGenerate: () -> Unit,
onRowClick: (StartlistenZeile) -> Unit
) {
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
PrimaryScrollableTabRow(
selectedTabIndex = selectedIndex,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = SlBlue,
edgePadding = 0.dp,
) {
bewerbe.forEachIndexed { index, bewerb ->
Tab(
selected = selectedId == bewerb.id,
onClick = { onSelect(bewerb.id) },
text = { Text(bewerb.tag, fontSize = 12.sp) },
)
}
}
HorizontalDivider()
val selectedBewerb = bewerbe.getOrNull(selectedIndex)
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = selectedBewerb?.let { "${it.tag} ${it.name}" } ?: "Kein Bewerb ausgewählt",
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
)
Spacer(Modifier.weight(1f))
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Drucken", fontSize = 12.sp)
}
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Exportieren", fontSize = 12.sp)
}
}
// Tabellen-Header
Row(
modifier = Modifier.fillMaxWidth().background(SlHeaderBg).padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Abteilung", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp))
Text("Beginn", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
}
HorizontalDivider()
if (currentStartliste.isEmpty()) {
// Leere Liste
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine Starter vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(8.dp))
Text("Startliste wird nach Nennungsschluss generiert.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
Spacer(Modifier.height(16.dp))
Button(
onClick = onGenerate,
colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
) {
Text("Startliste generieren", fontSize = 13.sp)
}
}
}
} else {
// Liste anzeigen
androidx.compose.foundation.lazy.LazyColumn(modifier = Modifier.fillMaxSize()) {
items(currentStartliste.size) { index ->
val zeile = currentStartliste[index]
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onRowClick(zeile) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(zeile.nr.toString(), fontSize = 12.sp, modifier = Modifier.width(70.dp))
Text(zeile.pferd, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(zeile.reiter, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text("-", fontSize = 12.sp, modifier = Modifier.width(80.dp))
Text(zeile.zeit, fontSize = 12.sp, modifier = Modifier.width(70.dp))
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
}
}
@Composable
private fun StartlistenSortierPanel(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Sortierung & Zeit", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
HorizontalDivider()
// Sortierung
Text("Sortierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
SortierOption("Aufsteigend (Startnummer)")
SortierOption("Absteigend (Startnummer)")
SortierOption("Auslosung (zufällig)")
SortierOption("Alphabetisch (Pferd)")
SortierOption("Alphabetisch (Reiter)")
}
HorizontalDivider()
// Zeiten
Text("Zeiten", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
LabeledInput("Beginnzeit:", "08:00", "(hh:mm)")
LabeledInput("Reitdauer:", "02:00", "(mm:ss)")
LabeledInput("Umbau:", "10", "(mm)")
LabeledInput("Besichtigung:", "10", "(mm)")
HorizontalDivider()
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
modifier = Modifier.fillMaxWidth(),
) {
Text("Zeiten neu berechnen", fontSize = 12.sp)
}
}
}
@Composable
private fun SortierOption(label: String) {
var selected by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
RadioButton(selected = selected, onClick = { selected = !selected })
Text(label, fontSize = 12.sp)
}
}
@Composable
private fun LabeledInput(label: String, value: String, unit: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, fontSize = 12.sp, modifier = Modifier.width(100.dp))
OutlinedTextField(
value = value,
onValueChange = {},
modifier = Modifier.width(70.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
Spacer(Modifier.width(6.dp))
Text(unit, fontSize = 11.sp, color = Color(0xFF6B7280))
}
}
@@ -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<TurnierListItem> = emptyList(),
val filtered: List<TurnierListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
)
sealed interface TurnierIntent {
data object Load : TurnierIntent
data object Refresh : TurnierIntent
data class SearchChanged(val query: String) : TurnierIntent
data class Select(val id: Long?) : TurnierIntent
data object ClearError : TurnierIntent
}
class TurnierViewModel(
private val repo: TurnierRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(TurnierState(isLoading = true))
val state: StateFlow<TurnierState> = _state
init {
send(TurnierIntent.Load)
}
fun send(intent: TurnierIntent) {
when (intent) {
is TurnierIntent.Load -> load()
is TurnierIntent.Refresh -> load()
is TurnierIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is TurnierIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is TurnierIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
repo.list()
.onSuccess { list ->
val items = list.map { it.toListItem() }
reduce { cur ->
val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered)
}
}
.onFailure { t ->
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
}
}
}
private fun Turnier.toListItem() = TurnierListItem(
id = id,
name = name,
ort = "Stadl-Paura", // Platzhalter bis API erweitert
startDatum = "2026-05-01",
endDatum = "2026-05-03",
status = "AKTIV"
)
private fun filter() {
val cur = _state.value
val filtered = filterList(cur.list, cur.searchQuery)
reduce { it.copy(filtered = filtered) }
}
private fun filterList(list: List<TurnierListItem>, query: String): List<TurnierListItem> {
if (query.isBlank()) return list
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.ort.contains(q, ignoreCase = true) ||
it.status.contains(q, ignoreCase = true)
}
}
private inline fun reduce(block: (TurnierState) -> TurnierState) {
_state.value = block(_state.value)
}
}
@@ -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)
)
}
}
@@ -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)),
)
@@ -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
@@ -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
// ─────────────────────────────────────────────────────────────────────────────