chore: remove obsolete screens from meldestelle-desktop module
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Failing after 2m56s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Failing after 3m3s
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Failing after 2m49s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Successful in 2m13s

- Deleted unused screens including `AdminUebersichtScreen`, `AktorScreens`, `StammdatenImportScreen`, `TurnierDetailScreen`, and supporting components such as `PlaceholderContent`.
- Cleaned up references and placeholders to streamline module structure.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-26 15:08:38 +01:00
parent 1d393fdefe
commit c2b3b5889f
50 changed files with 5067 additions and 1016 deletions
@@ -61,6 +61,7 @@ kotlin {
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
implementation(compose.uiTooling)
}
jsMain.dependencies {
@@ -33,13 +33,13 @@ data class PingUiState(
val logs: List<LogEntry> = emptyList()
)
class PingViewModel(
open class PingViewModel(
private val apiClient: PingApi,
private val syncService: PingSyncService
) : ViewModel() {
var uiState by mutableStateOf(PingUiState())
private set
internal set
private fun addLog(source: String, message: String, isError: Boolean = false) {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
@@ -0,0 +1,109 @@
package at.mocode.ping.feature.presentation
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
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
// ─────────────────────────────────────────────────────────────────────────────
// 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
// ─────────────────────────────────────────────────────────────────────────────
@Preview
@Composable
fun PreviewPingScreen_Empty() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(PingUiState()),
onBack = {}
)
}
}
@Preview
@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 = {}
)
}
}
@Preview
@Composable
fun PreviewPingScreen_Loading() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(PingUiState(isLoading = true, isSyncing = true)),
onBack = {}
)
}
}
@Preview
@Composable
fun PreviewPingScreen_Error() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(
PingUiState(errorMessage = "Connection refused: Backend nicht erreichbar")
),
onBack = {}
)
}
}
@@ -0,0 +1,32 @@
/**
* Feature-Modul: Turnier-Verwaltung (Desktop-only)
* Kapselt alle Screens und Tabs für Turnier-Detail, -Neuanlage und alle Turnier-Tabs
* (Stammdaten, Organisation, Bewerbe, Artikel, Abrechnung, Nennungen, Startlisten, Ergebnislisten).
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.navigation)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(libs.bundles.kmp.common)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
}
}
@@ -0,0 +1,73 @@
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+).",
)
}
}
@@ -0,0 +1,277 @@
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
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() {
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
TabRow(
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()) {
TabRow(
selectedTabIndex = sidebarTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = Color(0xFF1E3A8A),
) {
sidebarTabs.forEachIndexed { i, title ->
Tab(
selected = sidebarTab == i,
onClick = { sidebarTab = i },
text = { Text(title, fontSize = 11.sp) },
)
}
}
when (sidebarTab) {
2 -> BuchungenSidebar()
else -> PlaceholderContent(title = sidebarTabs[sidebarTab], subtitle = "")
}
}
}
}
@Composable
private fun BuchungenContent(buchungen: List<BuchungspositionUiModel>) {
val gesamtSoll = buchungen.sumOf { it.soll }
val gesamtHaben = buchungen.sumOf { it.haben }
val gesamtSaldo = gesamtSoll - gesamtHaben
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("Aktualisieren", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Text("Übersicht", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Text("Tabelle Leeren", fontSize = 12.sp, color = Color(0xFFEA580C))
}
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Text("Pferd aus Liste entfernen", fontSize = 12.sp)
}
}
// Tabellen-Header
Row(
modifier = Modifier.fillMaxWidth().background(Color(0xFFF3F4F6)).padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text("Buchungstext", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f))
Text("Soll", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Haben", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Saldo", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Buchen", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
Text("Rechnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
}
HorizontalDivider()
LazyColumn(modifier = Modifier.weight(1f)) {
items(buchungen) { b ->
val saldo = b.soll - b.haben
var buchen by remember { mutableStateOf(false) }
var rechnung by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(b.buchungstext, fontSize = 13.sp, modifier = Modifier.weight(3f))
Text("%.2f €".format(b.soll), fontSize = 13.sp, modifier = Modifier.weight(1f))
Text("%.2f €".format(b.haben), fontSize = 13.sp, modifier = Modifier.weight(1f))
Text(
"%.2f €".format(saldo),
fontSize = 13.sp,
color = if (saldo > 0) OffenePostenRot else Color.Unspecified,
fontWeight = if (saldo > 0) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier.weight(1f),
)
Checkbox(checked = buchen, onCheckedChange = { buchen = it }, modifier = Modifier.width(60.dp))
Checkbox(checked = rechnung, onCheckedChange = { rechnung = it }, modifier = Modifier.width(70.dp))
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
// Gesamt-Zeile
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("GESAMT", fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(3f))
Text("%.2f €".format(gesamtSoll), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text("%.2f €".format(gesamtHaben), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text(
"%.2f €".format(gesamtSaldo),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = if (gesamtSaldo > 0) OffenePostenRot else Color.Unspecified,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun BuchungenSidebar() {
var suchtext by remember { mutableStateOf("") }
var zahlungsart by remember { mutableStateOf("BAR") }
Column(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Nach Reiter oder Pferd", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = suchtext,
onValueChange = { suchtext = it },
placeholder = { Text("Bitte auswählen...", fontSize = 12.sp) },
modifier = Modifier.fillMaxWidth().height(44.dp),
singleLine = true,
)
HorizontalDivider()
Text("Buchen:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("0.00 €", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Button(
onClick = {},
enabled = false,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Buchen") }
}
HorizontalDivider()
Text("Direkt Drucken:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) {
Icon(Icons.Default.Print, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("Saldo", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) {
Text("Rechnung", fontSize = 12.sp)
}
}
HorizontalDivider()
Text("Zahlungsart:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
listOf("BAR", "Scheck (+30 €)", "Bankomat", "Kreditkarte").forEach { art ->
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = zahlungsart == art, onClick = { zahlungsart = art })
Text(art, fontSize = 13.sp)
}
}
Button(
onClick = {},
enabled = false,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Gebühr buchen") }
// Hinweis
Surface(
color = Color(0xFFEFF6FF),
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)),
) {
Text(
"💡 Hinweis: Bei Barzahlung werden die Buchungen sofort verarbeitet. Scheck-Zahlungen erfordern eine zusätzliche Gebühr von 30 €.",
fontSize = 11.sp,
color = Color(0xFF1E40AF),
modifier = Modifier.padding(8.dp),
)
}
}
}
@Composable
private fun OffenePostenContent() {
PlaceholderContent(title = "Offene Posten", subtitle = "Alle offenen Forderungen …")
}
@Composable
private fun RechnungContent() {
PlaceholderContent(title = "Rechnung", subtitle = "Rechnungserstellung …")
}
// --- UI-Modelle ---
data class BuchungspositionUiModel(val buchungstext: String, val soll: Double, val haben: Double)
@@ -0,0 +1,263 @@
package at.mocode.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)
@@ -0,0 +1,644 @@
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.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
private val PrimaryBlue = Color(0xFF1E3A8A)
private val HeaderBg = Color(0xFFF1F5F9)
private val SelectedRowBg = Color(0xFFEFF6FF)
/**
* BEWERBE-Tab gemäß Vision_03 (Screenshots 0912).
*
* Layout: 3-spaltig
* - Links (140dp): Aktions-Buttons (Speichern, Rückgängig, Einfügen, Löschen, Teilen, Verschieben, Startliste, Ergebnisliste)
* - Mitte (flex): Datentabelle (Tag | Platz | Bewerb | Beginn | Ende | Bewerbname | ZNS | Nennungen)
* - Rechts (340dp): Detail-Panel mit Sub-Tabs (Bewerb | Bewertung | Geldpreise | Ort/Zeit)
*/
@Composable
fun BewerbeTabContent() {
var selectedIndex by remember { mutableIntStateOf(0) }
val bewerbe = remember { sampleBewerbe() }
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Aktions-Spalte ──────────────────────────────────────────────
BewerbeAktionsSpalte(modifier = Modifier.width(140.dp).fillMaxHeight())
VerticalDivider()
// ── Mittlere Tabelle ──────────────────────────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
// Toolbar über der Tabelle
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("Aktualisieren", fontSize = 12.sp)
}
Surface(
shape = MaterialTheme.shapes.small,
color = PrimaryBlue,
) {
Text(
text = "${bewerbe.size} Bewerbe",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
) {
Text("Filtern", fontSize = 12.sp)
}
}
// Tabellen-Header
BewerbeTableHeader()
HorizontalDivider()
// Tabellen-Zeilen
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(bewerbe) { index, bewerb ->
BewerbeTableRow(
bewerb = bewerb,
isSelected = index == selectedIndex,
onClick = { selectedIndex = index },
)
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
}
VerticalDivider()
// ── Rechtes Detail-Panel ──────────────────────────────────────────────
BewerbeDetailPanel(
bewerb = bewerbe.getOrNull(selectedIndex),
modifier = Modifier.width(340.dp).fillMaxHeight(),
)
}
}
@Composable
private fun BewerbeTableHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(HeaderBg)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TableHeaderCell("Tag", 90.dp)
TableHeaderCell("Platz", 50.dp)
TableHeaderCell("Bewerb", 55.dp)
TableHeaderCell("Beginn", 55.dp)
TableHeaderCell("Ende", 55.dp)
TableHeaderCell("Bewerbname", weight = 1f)
TableHeaderCell("ZNS", 45.dp)
TableHeaderCell("Nennungen", 75.dp)
}
}
@Composable
private fun RowScope.TableHeaderCell(text: String, width: androidx.compose.ui.unit.Dp? = null, weight: Float? = null) {
val mod = when {
weight != null -> Modifier.weight(weight)
width != null -> Modifier.width(width)
else -> Modifier
}
Text(
text = text,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF374151),
modifier = mod,
)
}
@Composable
private fun BewerbeTableRow(bewerb: BewerbUiModel, isSelected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (isSelected) SelectedRowBg else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(bewerb.tag, fontSize = 12.sp, modifier = Modifier.width(90.dp))
Text("${bewerb.platz}", fontSize = 12.sp, modifier = Modifier.width(50.dp))
Text(
"${bewerb.nummer}",
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.width(55.dp),
color = if (isSelected) PrimaryBlue else Color.Unspecified
)
Text(bewerb.beginn, fontSize = 12.sp, modifier = Modifier.width(55.dp))
Text(bewerb.ende, fontSize = 12.sp, modifier = Modifier.width(55.dp))
Text(bewerb.name, fontSize = 12.sp, modifier = Modifier.weight(1f), maxLines = 2)
Text("${bewerb.zns}", fontSize = 12.sp, modifier = Modifier.width(45.dp))
Text("${bewerb.nennungen}", fontSize = 12.sp, modifier = Modifier.width(75.dp))
}
}
@Composable
private fun BewerbeAktionsSpalte(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
AktionsBtn("Änderungen\nSpeichern")
AktionsBtn("Änderungen\nRückgängig")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn("Bewerb\nEinfügen")
AktionsBtn("Bewerb\nLöschen")
AktionsBtn("Bewerb Teilen")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn("Bewerb nach\noben verschieben")
AktionsBtn("Bewerb nach\nunten verschieben")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn("Startliste\nBearbeiten")
AktionsBtn("Startliste\nDrucken")
AktionsBtn("Ergebnisliste\nBearbeiten")
AktionsBtn("Ergebnisliste\nDrucken")
}
}
@Composable
private fun AktionsBtn(label: String) {
OutlinedButton(
onClick = {},
modifier = Modifier.fillMaxWidth().height(48.dp),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp),
) {
Text(label, fontSize = 11.sp, lineHeight = 13.sp)
}
}
@Composable
private fun BewerbeDetailPanel(bewerb: BewerbUiModel?, modifier: Modifier = Modifier) {
var subTab by remember { mutableIntStateOf(0) }
val subTabs = listOf("Bewerb", "Bewertung", "Geldpreise", "Ort/Zeit")
Column(modifier = modifier) {
PrimaryTabRow(
selectedTabIndex = subTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = PrimaryBlue,
) {
subTabs.forEachIndexed { i, title ->
Tab(
selected = subTab == i,
onClick = { subTab = i },
text = { Text(title, fontSize = 12.sp) },
)
}
}
HorizontalDivider()
Box(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
when (subTab) {
0 -> BewerbSubTab(bewerb)
1 -> BewertungSubTab(bewerb)
2 -> GeldpreiseSubTab()
3 -> OrtZeitSubTab(bewerb)
}
}
}
}
// ── Sub-Tab: Bewerb ───────────────────────────────────────────────────────────
@Composable
private fun BewerbSubTab(bewerb: BewerbUiModel?) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
DetailField("Nummer:", bewerb?.nummer?.toString() ?: "")
DetailField("Abteilung:", "")
DetailField("Typ:", bewerb?.typ ?: "")
DetailField("Name:", bewerb?.name ?: "")
DetailField("Bezeichnung:", bewerb?.bezeichnung ?: "")
DetailDropdown("Kategorie:")
DetailDropdown("Klasse:")
DetailDropdown("Lizenz:")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Maximal:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = "3",
onValueChange = {},
modifier = Modifier.width(60.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
Spacer(Modifier.width(8.dp))
Text("Pferde je Reiter", fontSize = 12.sp, color = Color(0xFF6B7280))
}
DetailDropdown("Pferdealter:")
DetailField("Zeile 1:", bewerb?.zeile1 ?: "")
DetailField("Zeile 2:", "")
DetailField("Zeile 3:", "")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Logo Bewerb:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = "",
onValueChange = {},
modifier = Modifier.weight(1f),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
Spacer(Modifier.width(4.dp))
OutlinedButton(
onClick = {},
modifier = Modifier.height(40.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
Text("", fontSize = 13.sp)
}
}
}
}
// ── Sub-Tab: Bewertung ────────────────────────────────────────────────────────
@Composable
private fun BewertungSubTab(bewerb: BewerbUiModel?) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Bewertungs-Konfiguration", fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = Color(0xFF374151))
DetailField("Prüfung:", "Dressurreiterprüfung")
DetailField("Richtverfahren:", "A")
DetailField("Para-Grade:", "")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Richteranzahl:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = "2",
onValueChange = {},
modifier = Modifier.width(60.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
}
DetailField("Aufgabe:", "Aufgabe R")
DetailField("Aufgabennummer:", "")
DetailField("Maximalpunkte:", "")
HorizontalDivider()
Text("Richter", fontSize = 12.sp, color = Color(0xFF6B7280))
RichterRow("C:", "Schuster Alexandra")
RichterRow("C:", "Vankova Kamila (CZ)")
}
}
@Composable
private fun RichterRow(position: String, name: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(position, fontSize = 13.sp, modifier = Modifier.width(30.dp))
OutlinedTextField(
value = name,
onValueChange = {},
modifier = Modifier.weight(1f),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
Checkbox(checked = true, onCheckedChange = {})
}
}
// ── Sub-Tab: Geldpreise ───────────────────────────────────────────────────────
@Composable
private fun GeldpreiseSubTab() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Geldpreis-Sektion
Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Geldpreis", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = false, onCheckedChange = {})
Text("Geldpreis", fontSize = 13.sp)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Startgeld:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = "15,00",
onValueChange = {},
modifier = Modifier.width(100.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Auszahlung:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
DetailDropdown("fortführend", modifier = Modifier.weight(1f))
}
}
}
// Geldpreis für Kadererreiter
Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Geldpreis für Kadererreiter", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = false, onCheckedChange = {})
Text("Geldpreis für Kadererreiter", fontSize = 13.sp)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Startgeld für Kadererreiter:", fontSize = 13.sp, modifier = Modifier.width(180.dp))
OutlinedTextField(
value = "15,00",
onValueChange = {},
modifier = Modifier.width(100.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
}
}
}
// Geldpreisvorlage
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Geldpreisvorlage wählen:", fontSize = 13.sp, modifier = Modifier.width(180.dp))
DetailDropdown("", modifier = Modifier.weight(1f))
}
// Tabelle
Text("0 Geldpreise", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Row(
modifier = Modifier.fillMaxWidth().background(HeaderBg).padding(horizontal = 8.dp, vertical = 6.dp),
) {
Text("Nummer", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Geldpreis", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
}
HorizontalDivider()
Text("", fontSize = 12.sp, color = Color(0xFF9CA3AF), modifier = Modifier.padding(8.dp))
}
}
// ── Sub-Tab: Ort/Zeit ─────────────────────────────────────────────────────────
@Composable
private fun OrtZeitSubTab(bewerb: BewerbUiModel?) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Tag:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
DetailDropdown(bewerb?.tag ?: "28.05.2023", modifier = Modifier.weight(1f))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Beginnzeit:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
DetailDropdown("fix um", modifier = Modifier.width(100.dp))
}
LabeledTimeField("Beginnzeit:", bewerb?.beginn ?: "08:00", "(hh:mm)")
LabeledTimeField("Reitdauer:", "02:00", "(mm:ss)")
LabeledTimeField("Umbau:", "10", "(mm)")
LabeledTimeField("Besichtigung:", "10", "(mm)")
LabeledTimeField("Stechen:", "", "(mm)")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Platz:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
DetailDropdown("Vorderer Turnierplatz", modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun LabeledTimeField(label: String, value: String, unit: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = value,
onValueChange = {},
modifier = Modifier.width(80.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
Spacer(Modifier.width(8.dp))
Text(unit, fontSize = 12.sp, color = Color(0xFF6B7280))
}
}
// ── Hilfs-Composables ─────────────────────────────────────────────────────────
@Composable
private fun DetailField(label: String, value: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = value,
onValueChange = {},
modifier = Modifier.weight(1f),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
}
}
@Composable
private fun DetailDropdown(placeholder: String, modifier: Modifier = Modifier) {
OutlinedTextField(
value = placeholder,
onValueChange = {},
modifier = modifier,
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
trailingIcon = {
Text("", fontSize = 10.sp, color = Color(0xFF6B7280))
},
)
}
// ── UI-Modell ─────────────────────────────────────────────────────────────────
data class BewerbUiModel(
val tag: String,
val platz: Int,
val nummer: Int,
val beginn: String,
val ende: String,
val name: String,
val bezeichnung: String,
val typ: String,
val zeile1: String,
val zns: Int,
val nennungen: Int,
)
private fun sampleBewerbe() = listOf(
BewerbUiModel(
"28.05.2023",
1,
1,
"08:00",
"08:00",
"Dressurreiterprüfung Reiterpass\n(Aufgabe R 1)\nPony Einsteiger Cup OO",
"Dressurreiterprüfung Reiterpass",
"Dressur",
"Pony Einsteiger Cup OO",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
2,
"08:20",
"08:20",
"Dressurreiterprüfung Reitenadel\n(Aufgabe R 4)\nPony Einsteiger Cup OO",
"Dressurreiterprüfung Reitenadel",
"Dressur",
"Pony Einsteiger Cup OO",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
3,
"08:40",
"08:40",
"Dressurreiterprüfung lsf. (Istzfrei)\n(Aufgabe LF 1)",
"Dressurreiterprüfung lsf.",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
4,
"09:00",
"09:00",
"Dressurreiterprüfung lsf. (Lizenzfrei)\n(Aufgabe LF 3)",
"Dressurreiterprüfung lsf.",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
5,
"09:20",
"09:20",
"Führzügelklasse\nOO Kids Cup",
"Führzügelklasse",
"Dressur",
"OO Kids Cup",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
6,
"09:40",
"09:40",
"First Ridden\nOO Kids Cup",
"First Ridden",
"Dressur",
"OO Kids Cup",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
7,
"10:00",
"10:00",
"Pony Dressurprüfung Kl. A (Aufgabe P 1)",
"Pony Dressurprüfung Kl. A",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
8,
"10:20",
"10:20",
"Dressurreiterprüfung Kl. A (Aufgabe DRA 1)",
"Dressurreiterprüfung Kl. A",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
9,
"10:40",
"10:40",
"Dressurreiterprüfung Kl. A (Aufgabe A 5)",
"Dressurreiterprüfung Kl. A",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
10,
"11:00",
"11:00",
"Pony Dressurprüfung Kl. A (Aufgabe P 9)",
"Pony Dressurprüfung Kl. A",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
11,
"11:20",
"11:20",
"Dressurreiterprüfung Kl. L (Aufgabe DRL 1)",
"Dressurreiterprüfung Kl. L",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
12,
"11:40",
"11:40",
"Dressurprüfung Kl. L (Aufgabe L 3)",
"Dressurprüfung Kl. L",
"Dressur",
"",
0,
0
),
)
@@ -0,0 +1,93 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Detailansicht eines Turniers gemäß Vision_03.
*
* Layout: Horizontale Tab-Bar mit 8 Tabs (kein eigener Toolbar-Zurück-Button
* Navigation erfolgt über den Breadcrumb in der TopBar).
*
* Tabs:
* 1. STAMMDATEN Turnier-Konfiguration, ZNS-Import, Sparten, Datum
* 2. ORGANISATION Funktionäre, Richterkollegium, Austragungsplätze
* 3. BEWERBE 3-spaltiges Layout (Aktionen | Tabelle | Detail-Panel)
* 4. ARTIKEL Gebühren, Stallungen & Boxen, Zusatzgebühren
* 5. ABRECHNUNG Buchungen, Offene Posten, Rechnung
* 6. NENNUNGEN Pferd+Reiter-Suche, Verkauf/Buchungen, Bewerbsübersicht
* 7. STARTLISTEN Bewerbs-Tabs, Sortierung, Zeit/Dauer
* 8. ERGEBNISLISTEN Bewerbs-Tabs, Platzierung & Geldpreise
*
*/
@Composable
fun TurnierDetailScreen(
veranstaltungId: Long,
turnierId: Long,
onBack: () -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf(
"STAMMDATEN",
"ORGANISATION",
"BEWERBE",
"ARTIKEL",
"ABRECHNUNG",
"NENNUNGEN",
"STARTLISTEN",
"ERGEBNISLISTEN",
)
Column(modifier = Modifier.fillMaxSize()) {
// Horizontale Tab-Bar (direkt unter der TopBar)
PrimaryScrollableTabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = Color(0xFF1E3A8A),
edgePadding = 0.dp,
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = {
Text(
text = title,
fontSize = 13.sp,
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal,
)
},
)
}
}
HorizontalDivider()
// Tab-Inhalte
Box(modifier = Modifier.fillMaxSize()) {
when (selectedTab) {
0 -> StammdatenTabContent(turnierId = turnierId)
1 -> OrganisationTabContent()
2 -> BewerbeTabContent()
3 -> ArtikelTabContent()
4 -> AbrechnungTabContent()
5 -> NennungenTabContent()
6 -> StartlistenTabContent()
7 -> ErgebnislistenTabContent()
}
}
}
}
// Tab-Inhalte werden in dedizierten Dateien implementiert:
// TurnierBewerbeTab.kt → BewerbeTabContent()
// TurnierNennungenTab.kt → NennungenTabContent()
// TurnierStartlistenTab.kt → StartlistenTabContent()
// TurnierErgebnislistenTab.kt → ErgebnislistenTabContent()
@@ -0,0 +1,215 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background
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
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() {
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Bewerbs-Tabs + Tabelle ─────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
ErgebnislistenBewerbsTabs()
}
VerticalDivider()
// ── Rechte Spalte: Platzierung & Geldpreis ───────────────────────────
PlatzierungGeldpreisPanel(modifier = Modifier.width(280.dp).fillMaxHeight())
}
}
@Composable
private fun ErgebnislistenBewerbsTabs() {
val bewerbe = remember {
listOf("Bewerb 1", "Bewerb 2", "Bewerb 3", "Bewerb 4", "Bewerb 5")
}
var selectedBewerb by remember { mutableIntStateOf(0) }
PrimaryScrollableTabRow(
selectedTabIndex = selectedBewerb,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = ElBlue,
edgePadding = 0.dp,
) {
bewerbe.forEachIndexed { index, title ->
Tab(
selected = selectedBewerb == index,
onClick = { selectedBewerb = index },
text = { Text(title, fontSize = 12.sp) },
)
}
}
HorizontalDivider()
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Bewerb ${selectedBewerb + 1} Ergebnisliste",
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
)
Spacer(Modifier.weight(1f))
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Importieren", fontSize = 12.sp)
}
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Exportieren", fontSize = 12.sp)
}
OutlinedButton(
onClick = {},
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()
// 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)
}
}
}
}
}
@Composable
private fun PlatzierungGeldpreisPanel(modifier: Modifier = Modifier) {
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 = "3",
onValueChange = {},
modifier = Modifier.width(60.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Stechen ab Platz:", fontSize = 12.sp, modifier = Modifier.width(140.dp))
OutlinedTextField(
value = "",
onValueChange = {},
modifier = Modifier.width(60.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
}
HorizontalDivider()
// Geldpreise
Text("Geldpreise", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
Row(
modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp))
Text("Betrag (€)", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
}
HorizontalDivider()
listOf(1 to "", 2 to "", 3 to "").forEach { (platz, betrag) ->
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"$platz.",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = ElBlue,
modifier = Modifier.width(50.dp)
)
OutlinedTextField(
value = betrag,
onValueChange = {},
modifier = Modifier.weight(1f).height(36.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
HorizontalDivider()
// Aktions-Buttons
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
modifier = Modifier.fillMaxWidth(),
) {
Text("Platzierung berechnen", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) {
Text("Ergebnisliste drucken", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) {
Text("Geldpreise auszahlen", fontSize = 12.sp)
}
}
}
}
@@ -0,0 +1,251 @@
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() {
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Suche + Tabelle ─────────────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
NennungenSuchePanel()
HorizontalDivider()
NennungenTabelle()
}
VerticalDivider()
// ── Rechte Spalte: Verkauf + Bewerbsübersicht ─────────────────────────
Column(
modifier = Modifier
.width(360.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
) {
VerkaufBuchungenPanel()
HorizontalDivider()
BewerbsuebersichtPanel()
}
}
}
@Composable
private fun NennungenSuchePanel() {
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 = "",
onValueChange = {},
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 = "",
onValueChange = {},
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 = {},
colors = ButtonDefaults.buttonColors(containerColor = NennBlue),
modifier = Modifier.height(44.dp),
) {
Text("Suchen", fontSize = 12.sp)
}
}
}
}
@Composable
private fun NennungenTabelle() {
val nennungen = remember { sampleNennungen() }
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("Startnr.", 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("Bewerb", 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 (nennungen.isEmpty()) {
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 Nennung hinzuzufügen.",
fontSize = 12.sp,
color = Color(0xFF9CA3AF)
)
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(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.startnr}",
fontSize = 12.sp,
modifier = Modifier.width(60.dp),
color = NennBlue,
fontWeight = FontWeight.Bold
)
Text(nennung.pferd, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(nennung.reiter, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(nennung.bewerb, 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() {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Verkauf / Buchungen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
// 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,
)
private fun sampleNennungen(): List<NennungUiModel> = emptyList()
@@ -0,0 +1,65 @@
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 …")
}
}
}
}
@@ -0,0 +1,336 @@
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() {
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("") }
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") {
OrgSearchField("Turnierleiter:", turnierleiter) { turnierleiter = it }
OrgSearchField("Turnierbeauftragter:", turnierbeauftragter) { turnierbeauftragter = it }
OrgSearchField("Technischer Delegierter:", technischerDelegierter) { technischerDelegierter = it }
OrgSearchField("Parcourschef:", parcourschef) { parcourschef = it }
}
OrgSubSection("Support-Team") {
OrgSearchField("Tierarzt:", tierarzt) { tierarzt = it }
OrgSearchField("Schmied:", schmied) { schmied = it }
OrgSearchField("Steward:", steward) { steward = it }
}
}
// ── 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,
) {
OutlinedTextField(
value = r.name,
onValueChange = { v -> richter = richter.toMutableList().also { it[idx] = r.copy(name = v) } },
modifier = Modifier.weight(3f).height(44.dp).padding(end = 8.dp),
singleLine = true,
)
// 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.width(200.dp), color = Color(0xFF374151))
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = { Text("Name suchen...", fontSize = 12.sp) },
modifier = Modifier.weight(1f).height(44.dp),
singleLine = true,
)
}
}
// --- UI-Modelle ---
data class RichterUiModel(val name: String, val qualifikation: String, val funktion: String)
data class AustragungsplatzUiModel(val sparte: String, val groesse: String, val bezeichnung: String)
@@ -0,0 +1,261 @@
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.CloudDownload
import androidx.compose.material.icons.filled.Usb
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)
/**
* 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
*/
@Composable
fun StammdatenTabContent(turnierId: Long) {
var turnierNr by remember { mutableStateOf("") }
var typOto by remember { mutableStateOf(true) }
var spracheDe by remember { mutableStateOf(true) }
var sparteDressur by remember { mutableStateOf(false) }
var sparteSpringen by remember { mutableStateOf(false) }
var klasseC by remember { mutableStateOf(false) }
var klasseB by remember { mutableStateOf(false) }
var klasseA by remember { mutableStateOf(false) }
var datumVon by remember { mutableStateOf("") }
var datumBis by remember { mutableStateOf("") }
var titel by remember { mutableStateOf("") }
var subTitel by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ── Turnier-Konfiguration ────────────────────────────────────────────
SectionCard(title = "Turnier-Konfiguration") {
FormRow("Turnier-Nr.:") {
OutlinedTextField(
value = turnierNr,
onValueChange = { turnierNr = it },
placeholder = { Text("z.B. 26128", fontSize = 13.sp) },
modifier = Modifier.width(200.dp).height(48.dp),
singleLine = true,
)
}
FormRow("Typ:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = typOto, onClick = { typOto = true })
Text("OTO (National)", fontSize = 13.sp)
RadioButton(selected = !typOto, onClick = { typOto = false })
Text("FEI (International)", fontSize = 13.sp)
}
}
FormRow("ZNS-Daten:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue),
) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Import via Internet", fontSize = 13.sp)
}
OutlinedButton(onClick = {}) {
Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Import via USB", fontSize = 13.sp)
}
}
Text(
"Reiter-, Pferde-, Funktionärs- und Vereinsdaten vom OEPS Backend",
fontSize = 11.sp,
color = Color(0xFF6B7280),
)
}
FormRow("Sprache:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = spracheDe, onClick = { spracheDe = true })
Text("Deutsch", fontSize = 13.sp)
RadioButton(selected = !spracheDe, onClick = { spracheDe = false })
Text("English", fontSize = 13.sp)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
FormRow("Sparten:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = sparteDressur, onCheckedChange = { sparteDressur = it })
Text("Dressur", fontSize = 13.sp)
Checkbox(checked = sparteSpringen, onCheckedChange = { sparteSpringen = it })
Text("Springen", fontSize = 13.sp)
}
}
FormRow("Klassen:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = klasseC, onCheckedChange = { klasseC = it })
Text("C", fontSize = 13.sp)
Checkbox(checked = klasseB, onCheckedChange = { klasseB = it })
Text("B", fontSize = 13.sp)
Checkbox(checked = klasseA, onCheckedChange = { klasseA = it })
Text("A", fontSize = 13.sp)
}
}
FormRow("Kategorien:") {
Surface(
modifier = Modifier.fillMaxWidth().height(60.dp),
color = Color(0xFFF3F4F6),
shape = MaterialTheme.shapes.small,
) {
Box(contentAlignment = Alignment.Center) {
Text(
"Bitte Sparte(n) auswählen",
fontSize = 13.sp,
color = Color(0xFF9CA3AF),
)
}
}
}
FormRow("Datum:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = datumVon,
onValueChange = { datumVon = it },
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) },
modifier = Modifier.width(160.dp).height(48.dp),
singleLine = true,
)
Text("bis", fontSize = 13.sp)
OutlinedTextField(
value = datumBis,
onValueChange = { datumBis = it },
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) },
modifier = Modifier.width(160.dp).height(48.dp),
singleLine = true,
)
}
}
}
// ── Turnier-Beschreibung ─────────────────────────────────────────────
SectionCard(title = "Turnier-Beschreibung") {
OutlinedTextField(
value = titel,
onValueChange = { titel = it },
placeholder = { Text("z.B. Frühjahrs-Turnier 2026", fontSize = 13.sp) },
label = { Text("Titel") },
modifier = Modifier.fillMaxWidth().height(56.dp),
singleLine = true,
)
OutlinedTextField(
value = subTitel,
onValueChange = { subTitel = it },
placeholder = { Text("z.B. KIDS CUP • PONY EINSTEIGER CUP OÖ", fontSize = 13.sp) },
label = { Text("Sub-Titel") },
modifier = Modifier.fillMaxWidth().height(56.dp),
singleLine = true,
)
}
// ── Sponsoren ────────────────────────────────────────────────────────
SectionCard(
title = "Sponsoren",
action = {
TextButton(onClick = {}) {
Text("+ Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp)
}
},
) {
Surface(
modifier = Modifier.fillMaxWidth().height(80.dp),
color = Color(0xFFF9FAFB),
shape = MaterialTheme.shapes.small,
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Noch keine Sponsoren hinzugefügt", fontSize = 13.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(4.dp))
TextButton(onClick = {}) {
Text("+ Ersten Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp)
}
}
}
}
}
// ── Aktions-Buttons ──────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = {}) { Text("Zurücksetzen") }
Spacer(Modifier.width(8.dp))
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Speichern") }
}
}
}
@Composable
private fun SectionCard(
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 FormRow(label: String, content: @Composable ColumnScope.() -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
Text(
text = label,
fontSize = 13.sp,
modifier = Modifier.width(140.dp).padding(top = 12.dp),
color = Color(0xFF374151),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
content()
}
}
}
@@ -0,0 +1,182 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background
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
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() {
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Bewerbs-Tabs + Tabelle ─────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
StartlistenBewerbsTabs()
}
VerticalDivider()
// ── Rechte Spalte: Sortierung & Zeit ─────────────────────────────────
StartlistenSortierPanel(modifier = Modifier.width(280.dp).fillMaxHeight())
}
}
@Composable
private fun StartlistenBewerbsTabs() {
val bewerbe = remember {
listOf("Bewerb 1", "Bewerb 2", "Bewerb 3", "Bewerb 4", "Bewerb 5")
}
var selectedBewerb by remember { mutableIntStateOf(0) }
PrimaryScrollableTabRow(
selectedTabIndex = selectedBewerb,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = SlBlue,
edgePadding = 0.dp,
) {
bewerbe.forEachIndexed { index, title ->
Tab(
selected = selectedBewerb == index,
onClick = { selectedBewerb = index },
text = { Text(title, fontSize = 12.sp) },
)
}
}
HorizontalDivider()
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Bewerb ${selectedBewerb + 1} Startliste",
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()
// 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 = {},
colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
) {
Text("Startliste generieren", fontSize = 13.sp)
}
}
}
}
@Composable
private fun StartlistenSortierPanel(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Sortierung & Zeit", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
HorizontalDivider()
// Sortierung
Text("Sortierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
SortierOption("Aufsteigend (Startnummer)")
SortierOption("Absteigend (Startnummer)")
SortierOption("Auslosung (zufällig)")
SortierOption("Alphabetisch (Pferd)")
SortierOption("Alphabetisch (Reiter)")
}
HorizontalDivider()
// Zeiten
Text("Zeiten", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
LabeledInput("Beginnzeit:", "08:00", "(hh:mm)")
LabeledInput("Reitdauer:", "02:00", "(mm:ss)")
LabeledInput("Umbau:", "10", "(mm)")
LabeledInput("Besichtigung:", "10", "(mm)")
HorizontalDivider()
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
modifier = Modifier.fillMaxWidth(),
) {
Text("Zeiten neu berechnen", fontSize = 12.sp)
}
}
}
@Composable
private fun SortierOption(label: String) {
var selected by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
RadioButton(selected = selected, onClick = { selected = !selected })
Text(label, fontSize = 12.sp)
}
}
@Composable
private fun LabeledInput(label: String, value: String, unit: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, fontSize = 12.sp, modifier = Modifier.width(100.dp))
OutlinedTextField(
value = value,
onValueChange = {},
modifier = Modifier.width(70.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
Spacer(Modifier.width(6.dp))
Text(unit, fontSize = 11.sp, color = Color(0xFF6B7280))
}
}
@@ -0,0 +1,31 @@
/**
* Feature-Modul: Veranstalter-Verwaltung (Desktop-only)
* Kapselt alle Screens und Logik für Veranstalter-Auswahl, -Detail und -Neuanlage.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.navigation)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(libs.bundles.kmp.common)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
}
}
@@ -0,0 +1,259 @@
package at.mocode.veranstalter.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search
import androidx.compose.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.LoginStatus
import at.mocode.frontend.core.designsystem.models.LoginStatusBadge
private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6)
/**
* Screen: "Admin - Verwaltung / Veranstalter auswählen"
*
* Gemäß Figma Vision_03 (figma-entwurf_20 / figma-entwurf_22):
* - Titel + Untertitel
* - Suchfeld + "+ Neuer Veranstalter"-Button
* - Tabelle: Vereinsname, OEPS-Nummer, Ort, Ansprechpartner, E-Mail, Login-Status
* - Hinweis-Box
* - Abbrechen / "Weiter zum Veranstalter"-Buttons (unten)
*
* TODO: Echte Daten aus customer-context laden (Phase 4/5).
*/
@Composable
fun VeranstalterAuswahlScreen(
onZurueck: () -> Unit,
onWeiter: (Long) -> Unit,
onNeuerVeranstalter: () -> Unit = {},
) {
var selectedId by remember { mutableStateOf<Long?>(null) }
var suchtext by remember { mutableStateOf("") }
// Placeholder-Daten gemäß Figma
val veranstalter = remember {
listOf(
VeranstalterUiModel(
id = 1L,
name = "Reit- und Fahrverein Wels",
oepsNummer = "V-OOE-1234",
ort = "4600 Wels",
ansprechpartner = "Maria Huber",
email = "office@rfv-wels.at",
loginStatus = LoginStatus.AKTIV,
),
VeranstalterUiModel(
id = 2L,
name = "Pferdesportverein Linz",
oepsNummer = "V-OOE-5678",
ort = "4020 Linz",
ansprechpartner = "Thomas Maier",
email = "kontakt@psv-linz.at",
loginStatus = LoginStatus.AKTIV,
),
VeranstalterUiModel(
id = 3L,
name = "Reitclub Eferding",
oepsNummer = "V-OOE-9012",
ort = "4070 Eferding",
ansprechpartner = "Anna Schmid",
email = "info@rc-eferding.at",
loginStatus = LoginStatus.AUSSTEHEND,
),
)
}
val gefiltert = veranstalter.filter {
suchtext.isBlank() ||
it.name.contains(suchtext, ignoreCase = true) ||
it.oepsNummer.contains(suchtext, ignoreCase = true) ||
it.ort.contains(suchtext, ignoreCase = true)
}
Column(modifier = Modifier.fillMaxSize()) {
// ── Titel ────────────────────────────────────────────────────────────
Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) {
Text(
text = "Veranstalter für neue Veranstaltung auswählen",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = "Wählen Sie einen bestehenden Veranstalter aus oder legen Sie einen neuen Veranstalter an.",
fontSize = 13.sp,
color = Color(0xFF6B7280),
)
}
// ── Suchfeld + Neuer Veranstalter ────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = suchtext,
onValueChange = { suchtext = it },
placeholder = { Text("Veranstalter suchen (Name, OEPS-Nummer, Ort)...", fontSize = 13.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier.weight(1f).height(48.dp),
singleLine = true,
)
Button(
onClick = onNeuerVeranstalter,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neuer Veranstalter")
}
}
// ── Tabellen-Header ──────────────────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF3F4F6))
.padding(horizontal = 24.dp, vertical = 8.dp),
) {
Spacer(Modifier.width(28.dp)) // Checkmark-Spalte
Text("Vereinsname", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(2.5f))
Text("OEPS-Nummer", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f))
Text("Ort", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f))
Text("Ansprechpartner", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f))
Text("E-Mail", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(2f))
Text("Login", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1f))
}
HorizontalDivider()
// ── Tabellen-Inhalt ──────────────────────────────────────────────────
LazyColumn(modifier = Modifier.weight(1f)) {
items(gefiltert) { v ->
val isSelected = v.id == selectedId
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (isSelected) AccentBlue.copy(alpha = 0.08f)
else Color.Transparent,
)
.clickable { selectedId = v.id }
.padding(horizontal = 24.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Auswahl-Checkmark
Box(modifier = Modifier.width(28.dp)) {
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = AccentBlue,
modifier = Modifier.size(18.dp),
)
}
}
Text(
text = v.name,
fontSize = 13.sp,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
color = AccentBlue,
modifier = Modifier.weight(2.5f),
)
Text(v.oepsNummer, fontSize = 13.sp, modifier = Modifier.weight(1.5f))
Text(v.ort, fontSize = 13.sp, modifier = Modifier.weight(1.5f))
Text(v.ansprechpartner, fontSize = 13.sp, modifier = Modifier.weight(1.5f))
Text(v.email, fontSize = 13.sp, modifier = Modifier.weight(2f))
// Login-Status-Badge
Box(modifier = Modifier.weight(1f)) {
LoginStatusBadge(v.loginStatus)
}
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
// ── Hinweis-Box ──────────────────────────────────────────────────────
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
color = Color(0xFFEFF6FF),
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = AccentBlue,
modifier = Modifier.size(16.dp).padding(top = 2.dp),
)
Text(
text = "Veranstalter sind Vereine, die beim österreichischen Pferdesportverband (OEPS) registriert sind. " +
"Beim Anlegen eines neuen Veranstalters werden automatisch Login-Daten generiert und per E-Mail verschickt. " +
"Der Veranstalter kann dann sein Profil (Logo, Kontaktdaten, etc.) selbst verwalten.",
fontSize = 12.sp,
color = Color(0xFF1E40AF),
)
}
}
HorizontalDivider()
// ── Aktions-Buttons ──────────────────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onZurueck) {
Text("Abbrechen")
}
Spacer(Modifier.width(12.dp))
Button(
onClick = { selectedId?.let { onWeiter(it) } },
enabled = selectedId != null,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) {
Text("Weiter zum Veranstalter")
}
}
}
}
// --- UI-Modelle ---
data class VeranstalterUiModel(
val id: Long,
val name: String,
val oepsNummer: String,
val ort: String,
val ansprechpartner: String,
val email: String,
val loginStatus: LoginStatus,
)
@@ -0,0 +1,379 @@
package at.mocode.veranstalter.feature.presentation
import androidx.compose.foundation.BorderStroke
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.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.models.LoginStatus
import at.mocode.frontend.core.designsystem.models.LoginStatusBadge
import at.mocode.frontend.core.designsystem.models.VeranstaltungStatus
private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6)
private val StatusVorbereitungColor = Color(0xFFEA580C)
private val StatusLiveColor = Color(0xFF16A34A)
private val StatusAbgeschlossenColor = Color(0xFF6B7280)
/**
* Screen: "Admin - Verwaltung / Veranstalter auswählen / <Vereinsname>"
*
* Gemäß Figma Vision_03 (figma-entwurf_18 / figma-entwurf_19):
* - Veranstalter-Header: Avatar-Circle, Name, OEPS-Nr., Kontaktdetails-Grid, Login-Status, Mitglied-seit
* - Aktionsleiste: "+ Neue Veranstaltung", Suchfeld, Status-Filter-Chips
* - Veranstaltungs-Liste: Status-Badge, Datum, Ort, Turniere, Nennungen, Bewerbe, Letzte Aktivität, Bearbeiten-Icon
*
* TODO: Echte Daten aus customer-context / event-management-context laden (Phase 4/5).
*/
@Composable
fun VeranstalterDetailScreen(
veranstalterId: Long,
onZurueck: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit,
onVeranstaltungNeu: () -> Unit,
) {
var suchtext by remember { mutableStateOf("") }
var statusFilter by remember { mutableStateOf(VeranstaltungStatusFilter.ALLE) }
// Placeholder-Daten gemäß Figma
val veranstalter = remember(veranstalterId) {
VeranstalterDetailUiModel(
id = veranstalterId,
name = "Reit- und Fahrverein Wels",
oepsNummer = "V-OOE-1234",
ansprechpartner = "Maria Huber",
email = "office@rfv-wels.at",
telefon = "+43 7242 12345",
adresse = "Reitweg 15\n4600 Wels",
loginStatus = LoginStatus.AKTIV,
mitgliedSeit = "15.1.2023",
)
}
val veranstaltungen = remember(veranstalterId) {
listOf(
VeranstaltungListUiModel(
id = 1L,
name = "Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026",
datum = "25.-26. April 2026",
ort = "Reitanlage Stroblmair, Neumarkt/M., OO",
turnierAnzahl = 2,
nennungen = 87,
bewerbe = 26,
letzteAktivitaet = "22.03.2026 14:30",
status = VeranstaltungStatus.VORBEREITUNG,
),
VeranstaltungListUiModel(
id = 2L,
name = "AWÖ-Cup Stadl-Paura 2025",
datum = "15.-17. Mai 2025",
ort = "Bundesgestüt Piber, Stadl-Paura",
turnierAnzahl = 2,
nennungen = 142,
bewerbe = 33,
letzteAktivitaet = "17.05.2025 18:45",
status = VeranstaltungStatus.ABGESCHLOSSEN,
),
VeranstaltungListUiModel(
id = 3L,
name = "Linzer Pferdetage 2026",
datum = "12.-14. Juni 2026",
ort = "Reitsportzentrum Linz-Ebelsberg",
turnierAnzahl = 2,
nennungen = 23,
bewerbe = 30,
letzteAktivitaet = "20.03.2026 09:15",
status = VeranstaltungStatus.VORBEREITUNG,
),
)
}
val gefiltert = veranstaltungen.filter { v ->
val matchesStatus = when (statusFilter) {
VeranstaltungStatusFilter.ALLE -> true
VeranstaltungStatusFilter.VORBEREITUNG -> v.status == VeranstaltungStatus.VORBEREITUNG
VeranstaltungStatusFilter.LIVE -> v.status == VeranstaltungStatus.LIVE
VeranstaltungStatusFilter.ABGESCHLOSSEN -> v.status == VeranstaltungStatus.ABGESCHLOSSEN
}
val matchesSuche = suchtext.isBlank() ||
v.name.contains(suchtext, ignoreCase = true) ||
v.ort.contains(suchtext, ignoreCase = true)
matchesStatus && matchesSuche
}
Column(modifier = Modifier.fillMaxSize()) {
// ── Veranstalter-Header-Card ─────────────────────────────────────────
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White,
border = BorderStroke(1.dp, Color(0xFFE2E8F0)),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top,
) {
// Avatar-Circle
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(PrimaryBlue),
contentAlignment = Alignment.Center,
) {
Text(
text = veranstalter.name.first().uppercase(),
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = veranstalter.name,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = "OEPS-Nummer: ${veranstalter.oepsNummer}",
fontSize = 12.sp,
color = Color(0xFF6B7280),
)
Spacer(Modifier.height(6.dp))
// Kontaktdetails-Grid
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
KontaktSpalte("Ansprechpartner", veranstalter.ansprechpartner)
KontaktSpalte("E-Mail", veranstalter.email)
KontaktSpalte("Telefon", veranstalter.telefon)
KontaktSpalte("Adresse", veranstalter.adresse)
Column {
Text("Login-Status", fontSize = 11.sp, color = Color(0xFF9CA3AF))
Spacer(Modifier.height(2.dp))
LoginStatusBadge(veranstalter.loginStatus)
}
KontaktSpalte("Mitglied seit", veranstalter.mitgliedSeit)
}
}
}
// Profil bearbeiten
OutlinedButton(
onClick = { /* TODO */ },
border = BorderStroke(1.dp, Color(0xFFD1D5DB)),
) {
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("Profil bearbeiten", fontSize = 13.sp)
}
}
}
// ── Aktionsleiste ────────────────────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = onVeranstaltungNeu,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neue Veranstaltung")
}
OutlinedTextField(
value = suchtext,
onValueChange = { suchtext = it },
placeholder = { Text("Suche nach Name, Ort oder Turnier-Nr.", fontSize = 12.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
modifier = Modifier.weight(1f).height(44.dp),
singleLine = true,
)
// Status-Filter-Chips
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
VeranstaltungStatusFilter.entries.forEach { filter ->
val isActive = statusFilter == filter
FilterChip(
selected = isActive,
onClick = { statusFilter = filter },
label = {
Text(
text = when (filter) {
VeranstaltungStatusFilter.ALLE -> "Alle"
VeranstaltungStatusFilter.VORBEREITUNG -> "Vorbereitung"
VeranstaltungStatusFilter.LIVE -> "Live"
VeranstaltungStatusFilter.ABGESCHLOSSEN -> "Abgeschlossen"
},
fontSize = 12.sp,
)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = PrimaryBlue,
selectedLabelColor = Color.White,
),
)
}
}
}
// ── Veranstaltungs-Liste ─────────────────────────────────────────────
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(gefiltert) { veranstaltung ->
VeranstaltungListRow(
veranstaltung = veranstaltung,
onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) },
)
}
}
}
}
@Composable
private fun KontaktSpalte(label: String, wert: String) {
Column {
Text(label, fontSize = 11.sp, color = Color(0xFF9CA3AF))
Text(wert, fontSize = 12.sp, color = Color(0xFF374151))
}
}
@Composable
private fun VeranstaltungListRow(
veranstaltung: VeranstaltungListUiModel,
onOeffnen: () -> Unit,
) {
val statusColor = when (veranstaltung.status) {
VeranstaltungStatus.VORBEREITUNG -> StatusVorbereitungColor
VeranstaltungStatus.LIVE -> StatusLiveColor
VeranstaltungStatus.ABGESCHLOSSEN -> StatusAbgeschlossenColor
}
val statusText = when (veranstaltung.status) {
VeranstaltungStatus.VORBEREITUNG -> "Vorbereitung"
VeranstaltungStatus.LIVE -> "Live"
VeranstaltungStatus.ABGESCHLOSSEN -> "Abgeschlossen"
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White,
border = BorderStroke(1.dp, Color(0xFFE5E7EB)),
shape = MaterialTheme.shapes.small,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Status-Badge
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = statusColor.copy(alpha = 0.15f),
modifier = Modifier.width(100.dp),
) {
Text(
text = statusText,
color = statusColor,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
Spacer(Modifier.width(12.dp))
// Name + Meta
Column(modifier = Modifier.weight(1f)) {
Text(
text = veranstaltung.name,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
Spacer(Modifier.height(3.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text("📅 ${veranstaltung.datum}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("📍 ${veranstaltung.ort}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("🏆 ${veranstaltung.turnierAnzahl} Turniere", fontSize = 12.sp, color = Color(0xFF6B7280))
}
}
// Statistiken
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
StatSpalte("Nennungen", "${veranstaltung.nennungen}")
StatSpalte("Bewerbe", "${veranstaltung.bewerbe}")
StatSpalte("Letzte Aktivität", veranstaltung.letzteAktivitaet)
}
Spacer(Modifier.width(12.dp))
// Bearbeiten-Icon
IconButton(onClick = onOeffnen) {
Icon(
Icons.Default.Edit,
contentDescription = "Öffnen",
tint = Color(0xFF9CA3AF),
modifier = Modifier.size(18.dp),
)
}
}
}
}
@Composable
private fun StatSpalte(label: String, wert: String) {
Column(horizontalAlignment = Alignment.End) {
Text(label, fontSize = 10.sp, color = Color(0xFF9CA3AF))
Text(wert, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
// --- UI-Modelle ---
enum class VeranstaltungStatusFilter { ALLE, VORBEREITUNG, LIVE, ABGESCHLOSSEN }
data class VeranstalterDetailUiModel(
val id: Long,
val name: String,
val oepsNummer: String,
val ansprechpartner: String,
val email: String,
val telefon: String,
val adresse: String,
val loginStatus: LoginStatus,
val mitgliedSeit: String,
)
data class VeranstaltungListUiModel(
val id: Long,
val name: String,
val datum: String,
val ort: String,
val turnierAnzahl: Int,
val nennungen: Int,
val bewerbe: Int,
val letzteAktivitaet: String,
val status: VeranstaltungStatus,
)
@@ -0,0 +1,231 @@
package at.mocode.veranstalter.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.Info
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
/**
* Formular zum Anlegen eines neuen Veranstalters (Vision_03: Screenshot 21).
*
* Layout:
* - Info-Banner: "Login-Daten werden automatisch verschickt"
* - Abschnitt "Vereinsdaten": Vereinsname*, OEPS-Nummer*
* - Abschnitt "Kontaktdaten": Ansprechpartner*, E-Mail*, Telefon
* - Abschnitt "Adresse": Straße & Hausnummer, PLZ + Ort
* - Footer-Buttons: Abbrechen | Veranstalter anlegen & Login-Daten senden
*/
@Composable
fun VeranstalterNeuScreen(
onAbbrechen: () -> Unit,
onSpeichern: (vereinsname: String, oepsNummer: String, email: String) -> Unit,
) {
var vereinsname by remember { mutableStateOf("") }
var oepsNummer by remember { mutableStateOf("") }
var ansprechpartner by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var telefon by remember { mutableStateOf("") }
var strasse by remember { mutableStateOf("") }
var plz by remember { mutableStateOf("") }
var ort by remember { mutableStateOf("") }
val isValid = vereinsname.isNotBlank() && oepsNummer.isNotBlank() &&
ansprechpartner.isNotBlank() && email.isNotBlank()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
// Header
Column(modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp)) {
Text(
text = "Neuen Veranstalter anlegen",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(4.dp))
Text(
text = "Legen Sie einen neuen Veranstalter (Verein) mit OEPS-Daten an. Nach dem Speichern werden automatisch Login-Daten generiert.",
fontSize = 13.sp,
color = Color(0xFF6B7280),
)
}
// Info-Banner
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp),
color = Color(0xFFEFF6FF),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = Color(0xFF2563EB),
modifier = Modifier.size(20.dp),
)
Column {
Text(
text = "Login-Daten werden automatisch verschickt",
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = Color(0xFF1E40AF),
)
Spacer(Modifier.height(2.dp))
Text(
text = "Nach dem Anlegen werden Login-Daten generiert und an die angegebene E-Mail-Adresse verschickt. Der Veranstalter kann dann sein Profil selbst vervollständigen.",
fontSize = 12.sp,
color = Color(0xFF1E40AF),
)
}
}
}
Spacer(Modifier.height(24.dp))
// Formular-Card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// --- Vereinsdaten ---
Text("Vereinsdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField(
value = vereinsname,
onValueChange = { vereinsname = it },
label = { Text("Vereinsname *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Column {
OutlinedTextField(
value = oepsNummer,
onValueChange = { oepsNummer = it },
label = { Text("OEPS-Nummer *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
text = "Offizielle Vereinsnummer des OEPS",
fontSize = 11.sp,
color = Color(0xFF2563EB),
modifier = Modifier.padding(start = 4.dp, top = 2.dp),
)
}
HorizontalDivider()
// --- Kontaktdaten ---
Text("Kontaktdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField(
value = ansprechpartner,
onValueChange = { ansprechpartner = it },
label = { Text("Ansprechpartner *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Column {
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-Mail *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
text = "Login-Daten werden an diese Adresse verschickt",
fontSize = 11.sp,
color = Color(0xFF6B7280),
modifier = Modifier.padding(start = 4.dp, top = 2.dp),
)
}
OutlinedTextField(
value = telefon,
onValueChange = { telefon = it },
label = { Text("Telefon") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
HorizontalDivider()
// --- Adresse ---
Text("Adresse", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField(
value = strasse,
onValueChange = { strasse = it },
label = { Text("Straße & Hausnummer") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = plz,
onValueChange = { plz = it },
label = { Text("PLZ") },
modifier = Modifier.width(120.dp),
singleLine = true,
)
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Ort") },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
}
}
Spacer(Modifier.height(24.dp))
// Footer-Buttons
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onAbbrechen) {
Text("Abbrechen")
}
Spacer(Modifier.width(12.dp))
Button(
onClick = { onSpeichern(vereinsname, oepsNummer, email) },
enabled = isValid,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
) {
Text("Veranstalter anlegen & Login-Daten senden")
}
}
Spacer(Modifier.height(24.dp))
}
}
@@ -0,0 +1,31 @@
/**
* Feature-Modul: Veranstaltungs-Verwaltung (Desktop-only)
* Kapselt alle Screens und Logik für Veranstaltungs-Übersicht, -Detail und -Neuanlage.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.navigation)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(libs.bundles.kmp.common)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
}
}
@@ -0,0 +1,353 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
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 androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.models.VeranstaltungStatus
// Status-Farben gemäß Vision_03
private val StatusVorbereitung = Color(0xFFEA580C) // Orange
private val StatusLive = Color(0xFF16A34A) // Grün
private val StatusAbgeschlossen = Color(0xFF6B7280) // Grau
/**
* Root-Screen der Desktop-App gemäß Vision_03 (Screenshot 23/24).
*
* Layout:
* - KPI-Kacheln oben (Live/Aktiv, In Vorbereitung, Gesamt, Archiv)
* - Toolbar: "+ Neue Veranstaltung" + Suche + Status-Filter
* - Veranstaltungs-Cards (expandiert mit Turnier-Liste)
*
* TODO: Echte Daten aus event-management-context laden (Phase 4/5).
*/
@Composable
fun AdminUebersichtScreen(
onVeranstalterAuswahl: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit,
onPingService: () -> Unit = {},
) {
// Placeholder-Daten für die UI-Struktur
val veranstaltungen = listOf<VeranstaltungUiModel>() // leer bis Backend angebunden
Column(modifier = Modifier.fillMaxSize()) {
// KPI-Kacheln
KpiKachelRow(
liveAktiv = 0,
inVorbereitung = 0,
gesamt = 0,
archiv = 0,
)
// Toolbar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onVeranstalterAuswahl,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neue Veranstaltung")
}
OutlinedTextField(
value = "",
onValueChange = {},
placeholder = { Text("Suche nach Name, Ort oder Turnier-Nr.", fontSize = 13.sp) },
modifier = Modifier.weight(1f).height(48.dp),
singleLine = true,
)
OutlinedButton(onClick = onPingService) {
Text("🔧 Ping")
}
// Status-Filter Chips
StatusFilterChip("Alle", selected = true)
StatusFilterChip("Vorbereitung", selected = false)
StatusFilterChip("Live", selected = false)
StatusFilterChip("Abgeschlossen", selected = false)
}
HorizontalDivider()
// Veranstaltungs-Liste
if (veranstaltungen.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Noch keine Veranstaltungen",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Lege eine neue Veranstaltung an, um zu beginnen.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Button(
onClick = onVeranstalterAuswahl,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neue Veranstaltung anlegen")
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
) {
items(veranstaltungen) { veranstaltung ->
VeranstaltungCard(
veranstaltung = veranstaltung,
onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) },
onLoeschen = { /* TODO */ },
)
}
}
}
}
}
@Composable
private fun KpiKachelRow(
liveAktiv: Int,
inVorbereitung: Int,
gesamt: Int,
archiv: Int,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
KpiKachel(
label = "LIVE / AKTIV",
wert = liveAktiv.toString(),
akzentFarbe = StatusLive,
modifier = Modifier.weight(1f),
)
KpiKachel(
label = "IN VORBEREITUNG",
wert = inVorbereitung.toString(),
akzentFarbe = Color(0xFF3B82F6),
modifier = Modifier.weight(1f),
)
KpiKachel(
label = "GESAMT",
wert = gesamt.toString(),
akzentFarbe = Color(0xFF6B7280),
modifier = Modifier.weight(1f),
)
KpiKachel(
label = "ARCHIV",
wert = archiv.toString(),
akzentFarbe = Color(0xFF9CA3AF),
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun KpiKachel(
label: String,
wert: String,
akzentFarbe: Color,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
border = BorderStroke(2.dp, akzentFarbe),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = label,
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
Text(
text = wert,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = akzentFarbe,
)
}
}
}
@Composable
private fun StatusFilterChip(label: String, selected: Boolean) {
FilterChip(
selected = selected,
onClick = {},
label = { Text(label, fontSize = 12.sp) },
)
}
@Composable
private fun VeranstaltungCard(
veranstaltung: VeranstaltungUiModel,
onOeffnen: () -> Unit,
onLoeschen: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
border = if (veranstaltung.status == VeranstaltungStatus.VORBEREITUNG)
BorderStroke(1.dp, Color(0xFF3B82F6)) else null,
) {
Column(modifier = Modifier.padding(16.dp)) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = veranstaltung.name,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(top = 2.dp),
) {
Text("📍 ${veranstaltung.ort}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("📅 ${veranstaltung.datum}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("🏆 ${veranstaltung.turnierAnzahl} Turniere", fontSize = 12.sp, color = Color(0xFF6B7280))
}
}
StatusBadge(veranstaltung.status)
}
// Turnier-Liste
if (veranstaltung.turniere.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Turniere (${veranstaltung.turniere.size}):", fontSize = 12.sp, fontWeight = FontWeight.Medium)
veranstaltung.turniere.forEach { turnier ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Surface(
shape = MaterialTheme.shapes.small,
color = Color(0xFF1E3A8A),
) {
Text(
text = turnier.nummer.toString(),
color = Color.White,
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text("${turnier.name} (${turnier.bewerbAnzahl} Bewerbe)", fontSize = 12.sp)
}
OutlinedButton(onClick = onOeffnen, modifier = Modifier.height(28.dp)) {
Text("Öffnen", fontSize = 11.sp)
}
}
}
}
// Footer
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Nennungen: ${veranstaltung.nennungen}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("Letzte Aktivität: ${veranstaltung.letzteAktivitaet}", fontSize = 12.sp, color = Color(0xFF6B7280))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onOeffnen,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
modifier = Modifier.height(32.dp),
) {
Text("Öffnen", fontSize = 12.sp)
}
IconButton(onClick = onLoeschen, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626))
}
}
}
}
}
}
@Composable
private fun StatusBadge(status: VeranstaltungStatus) {
val (text, color) = when (status) {
VeranstaltungStatus.VORBEREITUNG -> "Vorbereitung" to StatusVorbereitung
VeranstaltungStatus.LIVE -> "Live" to StatusLive
VeranstaltungStatus.ABGESCHLOSSEN -> "Abgeschlossen" to StatusAbgeschlossen
}
Surface(
shape = MaterialTheme.shapes.small,
color = color.copy(alpha = 0.15f),
border = BorderStroke(1.dp, color),
) {
Text(
text = text,
color = color,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
// --- UI-Modelle (Placeholder bis echte Domain-Modelle angebunden sind) ---
data class VeranstaltungUiModel(
val id: Long,
val name: String,
val ort: String,
val datum: String,
val turnierAnzahl: Int,
val nennungen: Int,
val letzteAktivitaet: String,
val status: VeranstaltungStatus,
val turniere: List<TurnierUiModel> = emptyList(),
)
data class TurnierUiModel(
val id: Long,
val nummer: Long,
val name: String,
val bewerbAnzahl: Int,
)
@@ -0,0 +1,67 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
/**
* Detailansicht einer bestehenden Veranstaltung (Vision_03: /veranstaltung/{id}).
* Zeigt Übersicht-Tab mit Turniere-Section.
* TODO: Echte Daten laden (Phase 4/5).
*/
@Composable
fun VeranstaltungDetailScreen(
veranstaltungId: Long,
onBack: () -> Unit,
onTurnierNeu: () -> Unit,
onTurnierOeffnen: (Long) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(8.dp))
Text(
text = "Veranstaltung #$veranstaltungId",
style = MaterialTheme.typography.headlineSmall,
)
}
PrimaryTabRow(selectedTabIndex = 0) {
Tab(selected = true, onClick = {}, text = { Text("Veranstaltung Übersicht") })
}
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
// Turniere-Section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Turniere", style = MaterialTheme.typography.titleMedium)
OutlinedButton(onClick = onTurnierNeu) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neues Turnier")
}
}
Spacer(Modifier.height(16.dp))
PlaceholderContent(
title = "Noch keine Turniere",
subtitle = "Lege ein neues Turnier für diese Veranstaltung an.",
)
}
}
}
@@ -0,0 +1,64 @@
package at.mocode.veranstaltung.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 einer neuen Veranstaltung (Vision_03: /veranstaltung/neu).
* Tabs: Veranstaltung-Übersicht | Stammdaten (A-Satz) | Organisation | Preisliste
* TODO: Echte Formular-Felder und Persistenz (Phase 4/5).
*/
@Composable
fun VeranstaltungNeuScreen(
onBack: () -> Unit,
onSave: () -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(1) } // Stammdaten ist Standard-Tab
val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Preisliste")
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
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 = "Neue Veranstaltung",
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("Veranstaltung Übersicht", "Wird nach dem Speichern befüllt.")
1 -> PlaceholderContent("Stammdaten (A-Satz)", "Felder: Bezeichnung, Datum, Ort, Veranstalter …")
2 -> PlaceholderContent("Organisation", "Felder: Richter, Parcourschef, Tierarzt …")
3 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
}
}
}
}
@@ -0,0 +1,370 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.VeranstaltungStatus
private val PrimaryBlue = Color(0xFF1E3A8A)
private val ImportOrange = Color(0xFFEA580C)
private val ExportBlue = Color(0xFF2563EB)
private val UsbColor = Color(0xFF7C3AED)
/**
* Screen: "Veranstaltung - Übersicht"
*
* Gemäß Figma Vision_03 (figma-entwurf_17):
* - Veranstaltungs-Header (Name, Datum, Ort, Status)
* - Liste aller Turniere dieser Veranstaltung als Cards
* - Jede Turnier-Card hat Buttons:
* - "Öffnen" → Meldestelle des Turniers öffnen (TurnierDetail)
* - "Import" → Datenbank-Sicherung importieren
* - "Export" → Datenbank-Sicherung exportieren
* - "ZNS" → ZNS-Import für dieses Turnier starten
*
* Warum ZNS hier? Jedes Turnier hat seine eigene Datenbank/Kassa.
* Der ZNS-Import muss daher turnierspezifisch sein.
*
* TODO: Echte Daten aus event-management-context laden (Phase 4/5).
*/
@Composable
fun VeranstaltungUebersichtScreen(
veranstalterId: Long,
veranstaltungId: Long,
onZurueck: () -> Unit,
onTurnierOeffnen: (turnierId: Long) -> Unit,
onTurnierNeu: () -> Unit,
onZnsImport: (turnierId: Long) -> Unit,
onDbImport: (turnierId: Long) -> Unit,
onDbExport: (turnierId: Long) -> Unit,
) {
// Placeholder-Daten
val veranstaltung = remember(veranstaltungId) {
VeranstaltungUiModel(
id = veranstaltungId,
name = "Frühjahrsturnier Wels 2026",
ort = "Wels",
datum = "15.04.2026",
turnierAnzahl = 3,
nennungen = 47,
letzteAktivitaet = "heute",
status = VeranstaltungStatus.VORBEREITUNG,
)
}
val turniere = remember(veranstaltungId) {
listOf(
TurnierKarteUiModel(
id = 1L,
nummer = 26128L,
name = "Dressurturnier",
sparte = "Dressur",
bewerbAnzahl = 8,
nennungen = 24,
status = VeranstaltungStatus.VORBEREITUNG,
datum = "15.04.2026",
),
TurnierKarteUiModel(
id = 2L,
nummer = 26129L,
name = "Springturnier",
sparte = "Springen",
bewerbAnzahl = 6,
nennungen = 18,
status = VeranstaltungStatus.VORBEREITUNG,
datum = "15.04.2026",
),
TurnierKarteUiModel(
id = 3L,
nummer = 26130L,
name = "Vielseitigkeitsturnier",
sparte = "Vielseitigkeit",
bewerbAnzahl = 4,
nennungen = 5,
status = VeranstaltungStatus.VORBEREITUNG,
datum = "16.04.2026",
),
)
}
Column(modifier = Modifier.fillMaxSize()) {
// Tab-Header gemäß Figma
TabRow(
selectedTabIndex = 0,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = Color(0xFF1E3A8A),
modifier = Modifier.fillMaxWidth(),
) {
Tab(
selected = true,
onClick = {},
text = { Text("VERANSTALTUNG - ÜBERSICHT", fontSize = 13.sp, fontWeight = FontWeight.Bold) },
)
}
HorizontalDivider()
// Veranstaltungs-Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color(0xFFF8FAFC),
border = BorderStroke(1.dp, Color(0xFFE2E8F0)),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
text = veranstaltung.name,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text("📍 ${veranstaltung.ort}", fontSize = 13.sp, color = Color(0xFF6B7280))
Text("📅 ${veranstaltung.datum}", fontSize = 13.sp, color = Color(0xFF6B7280))
Text("🏆 ${veranstaltung.turnierAnzahl} Turniere", fontSize = 13.sp, color = Color(0xFF6B7280))
Text("📋 ${veranstaltung.nennungen} Nennungen gesamt", fontSize = 13.sp, color = Color(0xFF6B7280))
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onZurueck) {
Text("← Zurück")
}
Button(
onClick = onTurnierNeu,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neues Turnier")
}
}
}
}
HorizontalDivider()
// Turnier-Liste
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Turniere (${turniere.size})",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.width(8.dp))
Text(
text = "Jedes Turnier hat eine eigene Datenbank und Kassa.",
fontSize = 12.sp,
color = Color(0xFF6B7280),
)
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(turniere) { turnier ->
TurnierKarte(
turnier = turnier,
onOeffnen = { onTurnierOeffnen(turnier.id) },
onZns = { onZnsImport(turnier.id) },
onImport = { onDbImport(turnier.id) },
onExport = { onDbExport(turnier.id) },
)
}
}
}
}
@Composable
private fun TurnierKarte(
turnier: TurnierKarteUiModel,
onOeffnen: () -> Unit,
onZns: () -> Unit,
onImport: () -> Unit,
onExport: () -> Unit,
) {
val statusColor = when (turnier.status) {
VeranstaltungStatus.VORBEREITUNG -> Color(0xFFEA580C)
VeranstaltungStatus.LIVE -> Color(0xFF16A34A)
VeranstaltungStatus.ABGESCHLOSSEN -> Color(0xFF6B7280)
}
val statusText = when (turnier.status) {
VeranstaltungStatus.VORBEREITUNG -> "Vorbereitung"
VeranstaltungStatus.LIVE -> "Live"
VeranstaltungStatus.ABGESCHLOSSEN -> "Abgeschlossen"
}
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Turnier-Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
// Turnier-Nummer Badge
Surface(
shape = MaterialTheme.shapes.small,
color = PrimaryBlue,
) {
Text(
text = "${turnier.nummer}",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
Column {
Text(
text = turnier.name,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("🏇 ${turnier.sparte}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("📅 ${turnier.datum}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("${turnier.bewerbAnzahl} Bewerbe", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("${turnier.nennungen} Nennungen", fontSize = 12.sp, color = Color(0xFF6B7280))
}
}
}
// Status-Badge
Surface(
shape = MaterialTheme.shapes.small,
color = statusColor.copy(alpha = 0.15f),
border = BorderStroke(1.dp, statusColor),
) {
Text(
text = statusText,
color = statusColor,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
Spacer(Modifier.height(12.dp))
HorizontalDivider(color = Color(0xFFE5E7EB))
Spacer(Modifier.height(12.dp))
// Aktions-Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Öffnen Hauptaktion
Button(
onClick = onOeffnen,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
modifier = Modifier.height(36.dp),
) {
Icon(
Icons.Default.FolderOpen,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Öffnen", fontSize = 13.sp)
}
// USB-Import
OutlinedButton(
onClick = onZns,
modifier = Modifier.height(36.dp),
) {
Icon(
Icons.Default.Usb,
contentDescription = null,
modifier = Modifier.size(15.dp),
)
Spacer(Modifier.width(4.dp))
Text("USB", fontSize = 13.sp)
}
Spacer(Modifier.weight(1f))
// Import / Export DB-Sicherung
OutlinedButton(
onClick = onImport,
border = BorderStroke(1.dp, ImportOrange),
modifier = Modifier.height(36.dp),
) {
Icon(
Icons.Default.FileUpload,
contentDescription = null,
tint = ImportOrange,
modifier = Modifier.size(15.dp),
)
Spacer(Modifier.width(4.dp))
Text("Import", fontSize = 13.sp, color = ImportOrange)
}
OutlinedButton(
onClick = onExport,
border = BorderStroke(1.dp, ExportBlue),
modifier = Modifier.height(36.dp),
) {
Icon(
Icons.Default.FileDownload,
contentDescription = null,
tint = ExportBlue,
modifier = Modifier.size(15.dp),
)
Spacer(Modifier.width(4.dp))
Text("Export", fontSize = 13.sp, color = ExportBlue)
}
}
}
}
}
// --- UI-Modelle ---
data class TurnierKarteUiModel(
val id: Long,
val nummer: Long,
val name: String,
val sparte: String,
val bewerbAnzahl: Int,
val nennungen: Int,
val status: VeranstaltungStatus,
val datum: String,
)
@@ -0,0 +1,51 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
/**
* Veranstaltungs-Übersicht (Drawer-Einstieg gemäß Vision_03).
* Zeigt Liste aller Veranstaltungen + Button "Neue Veranstaltung".
* TODO: Echte Daten aus dem event-management-context laden (Phase 4/5).
*/
@Composable
fun VeranstaltungenScreen(
onVeranstaltungNeu: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Veranstaltungen",
style = MaterialTheme.typography.headlineMedium,
)
Button(onClick = onVeranstaltungNeu) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Neue Veranstaltung")
}
}
Spacer(modifier = Modifier.height(24.dp))
// Platzhalter wird durch echte Daten ersetzt
PlaceholderContent(
title = "Noch keine Veranstaltungen",
subtitle = "Lege eine neue Veranstaltung an, um zu beginnen.",
)
}
}
@@ -1,9 +1,10 @@
/**
* Feature-Modul: ZNS-Stammdaten-Import (Desktop-only)
* Kapselt ViewModel, State und API-Kommunikation für den ZNS-Import.
* Kapselt ViewModel, State, API-Kommunikation und UI-Screen für den ZNS-Import.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
@@ -13,8 +14,17 @@ kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.auth)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.bundles.kmp.common)
implementation(libs.koin.core)
implementation(libs.ktor.client.core)
@@ -0,0 +1,257 @@
package at.mocode.zns.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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import at.mocode.zns.feature.ZnsImportViewModel
import org.koin.compose.viewmodel.koinViewModel
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
@Composable
fun StammdatenImportScreen(
viewModel: ZnsImportViewModel = koinViewModel(),
) {
val state = viewModel.state
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Titel
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.CloudUpload, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text("Stammdaten-Import (ZNS)", style = MaterialTheme.typography.headlineSmall)
}
HorizontalDivider()
// Datei-Auswahl
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Datei auswählen", style = MaterialTheme.typography.titleMedium)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = state.selectedFilePath ?: "",
onValueChange = {},
readOnly = true,
placeholder = { Text("Keine Datei ausgewählt…") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Button(
onClick = {
val path = pickZipFile()
if (path != null) viewModel.onFileSelected(path)
},
enabled = !state.isUploading && !(!state.isFinished && state.jobId != null),
) {
Icon(Icons.Default.FolderOpen, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Durchsuchen")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { viewModel.startImport() },
enabled = state.selectedFilePath != null && !state.isUploading && !(state.jobId != null && !state.isFinished),
) {
if (state.isUploading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
} else {
Icon(Icons.Default.Upload, contentDescription = null)
Spacer(Modifier.width(4.dp))
}
Text("Import starten")
}
if (state.isFinished || state.errorMessage != null) {
OutlinedButton(onClick = { viewModel.reset() }) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Zurücksetzen")
}
}
}
}
}
// Fehler-Banner
if (state.errorMessage != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.onErrorContainer)
}
}
}
// Fortschritt
if (state.jobId != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Status", style = MaterialTheme.typography.titleMedium)
StatusChip(state.jobStatus)
}
LinearProgressIndicator(
progress = { state.progress / 100f },
modifier = Modifier.fillMaxWidth().height(8.dp),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
state.progressDetail.ifBlank { "Warte auf Server…" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"${state.progress}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.isFinished && state.jobStatus == "ABGESCHLOSSEN") {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Text(
"Import erfolgreich abgeschlossen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
// Fehler-Liste
if (state.errors.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth().weight(1f),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
Text(
"Import-Fehler (${state.errors.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.error,
)
}
HorizontalDivider()
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(state.errors) { error ->
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text("", color = MaterialTheme.colorScheme.error)
Text(
error,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
}
}
}
}
}
}
}
}
@Composable
private fun StatusChip(status: String?) {
val (label, color) = when (status) {
"AUSSTEHEND" -> "Ausstehend" to MaterialTheme.colorScheme.outline
"ENTPACKEN" -> "Entpacken…" to MaterialTheme.colorScheme.tertiary
"LADE_VEREINE" -> "Lade Vereine…" to MaterialTheme.colorScheme.secondary
"LADE_REITER" -> "Lade Reiter…" to MaterialTheme.colorScheme.secondary
"LADE_PFERDE" -> "Lade Pferde…" to MaterialTheme.colorScheme.secondary
"LADE_RICHTER" -> "Lade Richter…" to MaterialTheme.colorScheme.secondary
"ABGESCHLOSSEN" -> "Abgeschlossen ✓" to MaterialTheme.colorScheme.primary
"FEHLER" -> "Fehler ✗" to MaterialTheme.colorScheme.error
else -> (status ?: "") to MaterialTheme.colorScheme.outline
}
Surface(
shape = RoundedCornerShape(12.dp),
color = color.copy(alpha = 0.15f),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = color,
)
}
}
/** Öffnet einen nativen JFileChooser (JVM-only) und gibt den Pfad der gewählten ZIP zurück. */
private fun pickZipFile(): String? {
val chooser = JFileChooser()
chooser.dialogTitle = "ZNS.zip auswählen"
chooser.fileFilter = FileNameExtensionFilter("ZIP-Archiv (*.zip)", "zip")
chooser.isAcceptAllFileFilterUsed = false
val result = chooser.showOpenDialog(null)
return if (result == JFileChooser.APPROVE_OPTION) chooser.selectedFile.absolutePath else null
}