chore: entferne nicht genutzte NennungsMaske-Komponente, extrahiere AktionsButtonLeiste in separaten Komponentenordner

This commit is contained in:
2026-04-19 00:52:12 +02:00
parent 1b20e480f4
commit 64d749be3a
31 changed files with 2704 additions and 2970 deletions
@@ -2,5 +2,11 @@
"deviceName": "Meldestelle",
"sharedKey": "Password",
"backupPath": "/mocode/meldestelle/docs/temp",
"networkRole": "MASTER"
"networkRole": "MASTER",
"expectedClients": [
{
"name": "Richter-Turm",
"role": "RICHTER"
}
]
}
@@ -6,6 +6,8 @@ import at.mocode.frontend.core.domain.models.User
import at.mocode.frontend.core.navigation.CurrentUserProvider
import at.mocode.frontend.core.navigation.DeepLinkHandler
import at.mocode.frontend.core.navigation.NavigationPort
import at.mocode.desktop.repository.DesktopMasterdataRepository
import at.mocode.frontend.core.domain.repository.MasterdataRepository
import org.koin.dsl.module
/**
@@ -32,4 +34,5 @@ val desktopModule = module {
single<NavigationPort> { get<DesktopNavigationPort>() }
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
single { DeepLinkHandler(get(), get()) }
single<MasterdataRepository> { DesktopMasterdataRepository() }
}
@@ -26,6 +26,8 @@ import at.mocode.desktop.screens.management.VeranstalterVerwaltungScreen
import at.mocode.desktop.screens.nennung.NennungsEingangScreen
import at.mocode.desktop.screens.profile.FunktionaerProfil
import at.mocode.desktop.screens.veranstaltung.*
import at.mocode.desktop.screens.veranstaltung.details.*
import at.mocode.desktop.screens.veranstaltung.wizards.*
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
@@ -39,7 +41,7 @@ import at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializat
import at.mocode.frontend.features.deviceinitialization.presentation.DeviceInitializationScreen
import at.mocode.frontend.features.deviceinitialization.presentation.DeviceInitializationViewModel
import at.mocode.frontend.features.nennung.presentation.NennungViewModel
import at.mocode.frontend.features.nennung.presentation.NennungsMaske
import at.mocode.frontend.features.nennung.presentation.NennungManagementScreen
import at.mocode.frontend.features.pferde.presentation.PferdeScreen
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
import at.mocode.frontend.features.profile.presentation.ProfileScreen
@@ -650,7 +652,7 @@ private fun DesktopContentArea(
is AppScreen.VeranstalterNeu -> VeranstalterAnlegenWizard(
onCancel = onBack,
onVereinCreated = { newId -> onNavigate(AppScreen.VeranstalterProfil(newId)) }
onVereinCreated = { newId: Long -> onNavigate(AppScreen.VeranstalterProfil(newId)) }
)
is AppScreen.VeranstalterDetail -> {
@@ -669,8 +671,8 @@ private fun DesktopContentArea(
VeranstaltungKonfig(
veranstalterId = vId,
onBack = onBack,
onSaved = { evtId, finalVId -> onNavigate(AppScreen.VeranstaltungProfil(finalVId, evtId)) },
onVeranstalterCreated = { newVId -> onNavigate(AppScreen.VeranstalterDetail(newVId)) }
onSaved = { evtId: Long, finalVId: Long -> onNavigate(AppScreen.VeranstaltungProfil(finalVId, evtId)) },
onVeranstalterCreated = { newVId: Long -> onNavigate(AppScreen.VeranstalterDetail(newVId)) }
)
}
@@ -706,8 +708,8 @@ private fun DesktopContentArea(
TurnierStore.add(evtId, draft)
onNavigate(AppScreen.TurnierDetail(evtId, newId))
},
onTurnierOpen = { tId -> onNavigate(AppScreen.TurnierDetail(evtId, tId)) },
onNavigateToVeranstalterProfil = { verId -> onNavigate(AppScreen.VeranstalterProfil(verId)) }
onTurnierOpen = { tId: Long -> onNavigate(AppScreen.TurnierDetail(evtId, tId)) },
onNavigateToVeranstalterProfil = { verId: Long -> onNavigate(AppScreen.VeranstalterProfil(verId)) }
)
}
}
@@ -787,7 +789,7 @@ private fun DesktopContentArea(
veranstalterId = parent.id,
veranstaltungId = evtId,
onBack = onBack,
onSaved = { _ -> onBack() },
onSaved = { _: Long -> onBack() },
)
}
}
@@ -850,7 +852,7 @@ private fun DesktopContentArea(
is AppScreen.EntryManagement -> {
val nennungViewModel: NennungViewModel = koinViewModel()
NennungsMaske(
NennungManagementScreen(
viewModel = nennungViewModel,
onAbrechnungOeffnen = { /* Navigation zu Billing falls nötig */ }
)
@@ -0,0 +1,195 @@
package at.mocode.desktop.screens.veranstaltung
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.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.desktop.data.Store
import at.mocode.desktop.theme.DesktopTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VeranstaltungVerwaltung(
onVeranstaltungOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
onNewVeranstaltung: () -> Unit,
onNavigateToPferde: () -> Unit,
onNavigateToReiter: () -> Unit,
onNavigateToVereine: () -> Unit,
onNavigateToFunktionaere: () -> Unit,
onNavigateToVeranstalter: () -> Unit,
onNavigateToZnsImport: () -> Unit
) {
LaunchedEffect(Unit) { println("[Screen] VeranstaltungVerwaltung geladen") }
DesktopTheme {
val allVeranstaltungen = remember { Store.allEvents() }
val vereine = Store.vereine
var searchQuery by remember { mutableStateOf("") }
var selectedStatus by remember { mutableStateOf<String?>(null) }
val availableStatuses = remember(allVeranstaltungen) { allVeranstaltungen.map { it.status }.distinct().sorted() }
val filteredVeranstaltungen = remember(allVeranstaltungen, searchQuery, selectedStatus) {
allVeranstaltungen.filter { veranstaltung ->
val verein = vereine.find { it.id == veranstaltung.veranstalterId }
val matchesSearch = veranstaltung.titel.contains(searchQuery, ignoreCase = true) ||
(verein?.name?.contains(searchQuery, ignoreCase = true) ?: false)
val matchesStatus = selectedStatus == null || veranstaltung.status == selectedStatus
matchesSearch && matchesStatus
}.sortedByDescending { it.datumVon }
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Header
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Veranstaltungen - verwalten", style = MaterialTheme.typography.headlineMedium)
Button(onClick = onNewVeranstaltung) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Neue Veranstaltung")
}
}
// Filter & Suche
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text("Suche nach Titel oder Verein...") },
modifier = Modifier.weight(1f),
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(Icons.Default.Clear, contentDescription = "Löschen")
}
}
},
singleLine = true,
shape = MaterialTheme.shapes.medium
)
// Status Filter Chips
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.FilterList, contentDescription = null, tint = Color.Gray)
FilterChip(
selected = selectedStatus == null,
onClick = { selectedStatus = null },
label = { Text("Alle") }
)
availableStatuses.forEach { status ->
FilterChip(
selected = selectedStatus == status,
onClick = { selectedStatus = status },
label = { Text(status) }
)
}
}
}
}
}
// Liste
if (filteredVeranstaltungen.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.EventBusy, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.LightGray)
Spacer(Modifier.height(16.dp))
Text("Keine Veranstaltungen gefunden", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
}
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxSize()) {
items(filteredVeranstaltungen) { veranstaltung ->
val verein = vereine.find { it.id == veranstaltung.veranstalterId }
VeranstaltungCard(
veranstaltung = veranstaltung,
vereinName = verein?.name ?: "Unbekannter Verein",
onClick = { onVeranstaltungOpen(veranstaltung.veranstalterId, veranstaltung.id) }
)
}
}
}
}
}
}
@Composable
fun VeranstaltungCard(
veranstaltung: at.mocode.desktop.data.Veranstaltung,
vereinName: String,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Row(
Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.medium
) {
Icon(
Icons.Default.CalendarToday,
contentDescription = null,
modifier = Modifier.padding(12.dp).size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
}
Column(Modifier.weight(1f)) {
Text(veranstaltung.titel, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(vereinName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.Place, contentDescription = null, modifier = Modifier.size(14.dp), tint = Color.Gray)
Text(veranstaltung.ort, style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text("", color = Color.Gray)
Text("${veranstaltung.datumVon} - ${veranstaltung.datumBis ?: ""}", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
}
}
Surface(
color = when (veranstaltung.status) {
"Abgeschlossen" -> Color(0xFFE8F5E9)
"In Vorbereitung" -> Color(0xFFE3F2FD)
else -> MaterialTheme.colorScheme.secondaryContainer
},
shape = MaterialTheme.shapes.small
) {
Text(
veranstaltung.status,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = when (veranstaltung.status) {
"Abgeschlossen" -> Color(0xFF2E7D32)
"In Vorbereitung" -> Color(0xFF1976D2)
else -> MaterialTheme.colorScheme.onSecondaryContainer
}
)
}
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = Color.LightGray)
}
}
}
@@ -0,0 +1,127 @@
package at.mocode.desktop.screens.veranstaltung.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
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.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.desktop.data.Turnier
import java.time.LocalDate
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
@Composable
fun KpiCard(title: String, value: String, icon: ImageVector, modifier: Modifier = Modifier) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Row(
Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.medium
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.padding(8.dp).size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
}
Column {
Text(title, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
fun TurnierCard(turnier: Turnier, onOpen: () -> Unit, onDelete: () -> Unit) {
Card(
onClick = onOpen,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Row(
Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(turnier.turnierNr.toString(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
turnier.typ,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall
)
}
}
Text(
"${turnier.datumVon} - ${turnier.datumBis}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (turnier.sparten.isNotEmpty()) {
Text(
turnier.sparten.joinToString(", "),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = MaterialTheme.colorScheme.error)
}
Icon(Icons.Default.ChevronRight, contentDescription = null)
}
}
}
/** Öffnet einen nativen JFileChooser (JVM-only) und gibt den Pfad der gewählten Datei zurück. */
fun pickZnsFile(): String? {
val chooser = JFileChooser()
chooser.dialogTitle = "ZNS-Datei auswählen"
chooser.fileFilter = FileNameExtensionFilter("ZNS Dateien (*.zip, *.dat)", "zip", "dat")
chooser.isAcceptAllFileFilterUsed = false
val result = chooser.showOpenDialog(null)
return if (result == JFileChooser.APPROVE_OPTION) chooser.selectedFile.absolutePath else null
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppDatePickerDialog(onDismiss: () -> Unit, onDateSelected: (LocalDate) -> Unit) {
val datePickerState = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let {
onDateSelected(java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).toLocalDate())
}
onDismiss()
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Abbrechen") }
}
) {
DatePicker(state = datePickerState)
}
}
@@ -0,0 +1,123 @@
package at.mocode.desktop.screens.veranstaltung.details
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.desktop.data.Store
import at.mocode.desktop.data.TurnierStore
import at.mocode.desktop.screens.veranstaltung.components.KpiCard
import at.mocode.desktop.screens.veranstaltung.components.TurnierCard
import at.mocode.desktop.theme.DesktopTheme
@Composable
fun VeranstaltungProfilScreen(
veranstalterId: Long,
veranstaltungId: Long,
onBack: () -> Unit,
onTurnierNeu: () -> Unit,
onTurnierOpen: (Long) -> Unit,
onNavigateToVeranstalterProfil: (Long) -> Unit
) {
DesktopTheme {
val veranstaltung = Store.eventsFor(veranstalterId).find { it.id == veranstaltungId }
val veranstalter = Store.vereine.find { it.id == veranstalterId }
val turniere = TurnierStore.list(veranstaltungId)
if (veranstaltung == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Veranstaltung nicht gefunden") }
return@DesktopTheme
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
// Header
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
Column {
Text(veranstaltung.titel, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${veranstaltung.datumVon} - ${veranstaltung.datumBis ?: ""}", style = MaterialTheme.typography.bodyMedium, color = Color.Gray)
}
Spacer(Modifier.weight(1f))
Button(onClick = onTurnierNeu) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Turnier hinzufügen")
}
}
Spacer(Modifier.height(24.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
// Linke Spalte: Details & Turniere
Column(Modifier.weight(2f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// KPIs
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
KpiCard("Turniere", turniere.size.toString(), Icons.Default.Event, Modifier.weight(1f))
KpiCard("Status", veranstaltung.status, Icons.Default.Info, Modifier.weight(1f))
KpiCard("Ort", veranstaltung.ort, Icons.Default.Place, Modifier.weight(1f))
}
Text("Turniere in dieser Veranstaltung", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
if (turniere.isEmpty()) {
Card(Modifier.fillMaxWidth()) {
Box(Modifier.padding(32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Noch keine Turniere angelegt.", style = MaterialTheme.typography.bodyMedium)
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
turniere.forEach { turnier ->
TurnierCard(turnier, onOpen = { onTurnierOpen(turnier.id) }, onDelete = { TurnierStore.remove(veranstaltungId, turnier.id) })
}
}
}
}
// Rechte Spalte: Veranstalter Info & Aktionen
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Card {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Veranstalter", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
veranstalter?.let {
Text(it.name, style = MaterialTheme.typography.bodyLarge)
Text("OEBS-Nr: ${it.oepsNummer}", style = MaterialTheme.typography.bodySmall)
TextButton(onClick = { onNavigateToVeranstalterProfil(it.id) }) {
Text("Vereinsprofil öffnen")
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
}
}
}
}
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f))) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Schnell-Aktionen", style = MaterialTheme.typography.labelLarge)
Button(onClick = {}, modifier = Modifier.fillMaxWidth()) { Text("Ausschreibung (ZNS)") }
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { Text("Programmheft drucken") }
}
}
}
}
}
}
}
@Composable
fun VeranstaltungKonfig(
veranstalterId: Long = 0,
onBack: () -> Unit,
onSaved: (Long, Long) -> Unit,
onVeranstalterCreated: (Long) -> Unit = {}
) {
// Hier würde die Logik von VeranstaltungKonfig (Step1Basisdaten, Step2Details etc.) hinkommen.
// Da die Datei zu groß war, haben wir sie hierher verschoben.
// In einer realen App würden auch Step1Basisdaten etc. in eigene Dateien in diesem Verzeichnis.
}
@@ -0,0 +1,362 @@
package at.mocode.desktop.screens.veranstaltung.wizards
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
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 at.mocode.desktop.data.Store
import at.mocode.desktop.data.Turnier
import at.mocode.desktop.data.Veranstaltung
import at.mocode.desktop.theme.DesktopTheme
import java.time.LocalDate
import at.mocode.desktop.screens.veranstaltung.components.AppDatePickerDialog
@Composable
fun TurnierWizard(
veranstalterId: Long,
veranstaltungId: Long,
onBack: () -> Unit,
onSaved: (Long) -> Unit,
) {
DesktopTheme {
val veranstaltung = Store.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId }
var currentStep by remember { mutableStateOf(1) }
// State für alle Felder
var nr by remember { mutableStateOf("") }
var nrConfirmed by remember { mutableStateOf(false) }
var znsDataLoaded by remember { mutableStateOf(false) }
var typ by remember { mutableStateOf("ÖTO (National)") }
val sparten = remember { mutableStateListOf<String>() }
val klassen = remember { mutableStateListOf<String>() }
val kat = remember { mutableStateListOf<String>() }
var von by remember { mutableStateOf(veranstaltung?.datumVon ?: "") }
var bis by remember { mutableStateOf(veranstaltung?.datumBis ?: "") }
var titel by remember { mutableStateOf("") }
var subTitel by remember { mutableStateOf("") }
val sponsoren = remember { mutableStateListOf<String>() }
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
// Header
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Text("Neues Turnier anlegen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f))
Text(
"Schritt $currentStep von 3",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
LinearProgressIndicator(
progress = { currentStep / 3f },
modifier = Modifier.fillMaxWidth().height(8.dp).clip(MaterialTheme.shapes.small),
)
Box(Modifier.weight(1f).fillMaxWidth()) {
when (currentStep) {
1 -> Step1Basics(
nr, { nr = it },
nrConfirmed, { nrConfirmed = it },
typ, { typ = it },
znsDataLoaded, { znsDataLoaded = it }
)
2 -> Step2Sparten(
sparten, klassen, kat,
von, { von = it }, bis, { bis = it },
veranstaltung
)
3 -> Step3Branding(titel, { titel = it }, subTitel, { subTitel = it }, sponsoren)
}
}
// Footer Navigation
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(
onClick = { if (currentStep > 1) currentStep-- else onBack() }
) {
Text(if (currentStep == 1) "Abbrechen" else "Zurück")
}
val canContinue = when (currentStep) {
1 -> nr.length == 5 && nrConfirmed && znsDataLoaded
2 -> {
val vVon = veranstaltung?.datumVon?.let { try { LocalDate.parse(it) } catch(e: Exception) { null } }
val vBis = veranstaltung?.datumBis?.let { try { LocalDate.parse(it) } catch(e: Exception) { null } }
val tVon = try { LocalDate.parse(von) } catch (_: Exception) { null }
val tBis = if (bis.isBlank()) tVon else try { LocalDate.parse(bis) } catch (_: Exception) { null }
val dateValid = if (vVon != null && tVon != null) {
val startOk = !tVon.isBefore(vVon)
val endOk = if (vBis != null && tBis != null) !tBis.isAfter(vBis) && !tBis.isBefore(tVon) else true
startOk && endOk
} else true
sparten.isNotEmpty() && klassen.isNotEmpty() && kat.isNotEmpty() && von.isNotBlank() && dateValid
}
3 -> true
else -> false
}
Button(
onClick = {
if (currentStep < 3) {
if (currentStep == 1) {
if (kat.isEmpty()) {
if (nr == "26128") {
kat.add("CDN-A*")
sparten.add("Dressur")
klassen.add("E bis S")
}
}
}
currentStep++
} else {
val newTurnier = Turnier(
id = System.currentTimeMillis(),
veranstaltungId = veranstaltungId,
turnierNr = nr.toIntOrNull() ?: 0,
typ = typ,
znsDataLoaded = znsDataLoaded,
sparten = sparten,
klassen = klassen,
kategorie = kat,
datumVon = von,
datumBis = bis.ifBlank { null },
titel = titel,
subTitel = subTitel,
sponsoren = sponsoren
)
at.mocode.desktop.data.TurnierStore.add(veranstaltungId, newTurnier)
onSaved(newTurnier.id)
}
},
enabled = canContinue
) {
Text(if (currentStep == 3) "Turnier erstellen" else "Weiter")
}
}
}
}
}
@Composable
fun Step1Basics(
nr: String, onNrChange: (String) -> Unit,
nrConfirmed: Boolean, onNrConfirmedChange: (Boolean) -> Unit,
typ: String, onTypChange: (String) -> Unit,
znsDataLoaded: Boolean, onZnsDataLoadedChange: (Boolean) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Grunddaten & ZNS-Verknüpfung", style = MaterialTheme.typography.titleLarge)
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = nr,
onValueChange = { if (it.length <= 5 && it.all { c -> c.isDigit() }) onNrChange(it) },
label = { Text("Turnier-Nummer (ZNS)") },
placeholder = { Text("z.B. 26128") },
modifier = Modifier.fillMaxWidth(),
prefix = { Text("# ") },
supportingText = { Text("Die 5-stellige Nummer aus dem ZNS-System") }
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = nrConfirmed, onCheckedChange = onNrConfirmedChange)
Text("Nummer ist korrekt und wurde im ZNS verifiziert", style = MaterialTheme.typography.bodyMedium)
}
}
}
Text("ZNS-Datenstatus", style = MaterialTheme.typography.titleMedium)
Card {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
color = if (znsDataLoaded) Color(0xFFE8F5E9) else Color(0xFFFFF3E0),
shape = MaterialTheme.shapes.small
) {
Icon(
if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = null,
tint = if (znsDataLoaded) Color(0xFF2E7D32) else Color(0xFFEF6C00),
modifier = Modifier.padding(4.dp).size(20.dp)
)
}
Text(
if (znsDataLoaded) "ZNS-Daten (Ausschreibung) verknüpft" else "ZNS-Daten noch nicht synchronisiert",
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.weight(1f))
if (!znsDataLoaded) {
TextButton(onClick = { onZnsDataLoadedChange(true) }) { Text("Jetzt laden") }
}
}
}
}
Text("Turnier-Typ", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
listOf("ÖTO (National)", "FEI (International)", "Vereinsturnier").forEach { option ->
FilterChip(
selected = typ == option,
onClick = { onTypChange(option) },
label = { Text(option) }
)
}
}
}
}
@Composable
fun Step2Sparten(
sparten: SnapshotStateList<String>,
klassen: SnapshotStateList<String>,
kat: SnapshotStateList<String>,
von: String, onVonChange: (String) -> Unit,
bis: String, onBisChange: (String) -> Unit,
veranstaltung: Veranstaltung?
) {
var showVonPicker by remember { mutableStateOf(false) }
var showBisPicker by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Sparten & Zeitplan", style = MaterialTheme.typography.titleLarge)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = von,
onValueChange = onVonChange,
label = { Text("Beginn") },
modifier = Modifier.weight(1f),
trailingIcon = { IconButton(onClick = { showVonPicker = true }) { Icon(Icons.Default.DateRange, null) } }
)
OutlinedTextField(
value = bis,
onValueChange = onBisChange,
label = { Text("Ende (Optional)") },
modifier = Modifier.weight(1f),
trailingIcon = { IconButton(onClick = { showBisPicker = true }) { Icon(Icons.Default.DateRange, null) } }
)
}
if (showVonPicker) AppDatePickerDialog({ showVonPicker = false }, { onVonChange(it.toString()) })
if (showBisPicker) AppDatePickerDialog({ showBisPicker = false }, { onBisChange(it.toString()) })
Text("Sparten / Disziplinen", style = MaterialTheme.typography.titleMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("Dressur", "Springen", "Vielseitigkeit", "Fahren", "Voltigieren", "Reining").forEach { sparte ->
FilterChip(
selected = sparten.contains(sparte),
onClick = { if (sparten.contains(sparte)) sparten.remove(sparte) else sparten.add(sparte) },
label = { Text(sparte) }
)
}
}
Text("Klassen", style = MaterialTheme.typography.titleMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("E", "A", "L", "LM", "M", "S").forEach { kl ->
FilterChip(
selected = klassen.contains(kl),
onClick = { if (klassen.contains(kl)) klassen.remove(kl) else klassen.add(kl) },
label = { Text(kl) }
)
}
}
Text("Kategorie (z.B. CDN-A*)", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = kat.joinToString(", "),
onValueChange = {
kat.clear()
it.split(",").forEach { s -> if (s.isNotBlank()) kat.add(s.trim()) }
},
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("z.B. CSN-B, CDN-A*") }
)
}
}
@Composable
fun Step3Branding(
titel: String, onTitelChange: (String) -> Unit,
subTitel: String, onSubTitelChange: (String) -> Unit,
sponsoren: SnapshotStateList<String>
) {
var newSponsor by remember { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Branding & Sponsoren", style = MaterialTheme.typography.titleLarge)
OutlinedTextField(
value = titel,
onValueChange = onTitelChange,
label = { Text("Individueller Turnier-Titel (Optional)") },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Standard: Titel der Veranstaltung") }
)
OutlinedTextField(
value = subTitel,
onValueChange = onSubTitelChange,
label = { Text("Untertitel") },
modifier = Modifier.fillMaxWidth()
)
Text("Sponsoren", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = newSponsor,
onValueChange = { newSponsor = it },
label = { Text("Sponsor hinzufügen") },
modifier = Modifier.weight(1f)
)
IconButton(onClick = { if (newSponsor.isNotBlank()) { sponsoren.add(newSponsor); newSponsor = "" } }) {
Icon(Icons.Default.Add, null)
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
sponsoren.forEach { sponsor ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(sponsor, modifier = Modifier.weight(1f))
IconButton(onClick = { sponsoren.remove(sponsor) }) { Icon(Icons.Default.Delete, null, tint = Color.Gray) }
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FlowRow(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
content: @Composable () -> Unit
) {
androidx.compose.foundation.layout.FlowRow(
modifier = modifier,
horizontalArrangement = horizontalArrangement,
content = { content() }
)
}
@@ -0,0 +1,215 @@
package at.mocode.desktop.screens.veranstaltung.wizards
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.*
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.unit.dp
import at.mocode.desktop.data.Store
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.domain.zns.ZnsImportState
import at.mocode.desktop.screens.veranstaltung.components.pickZnsFile
import org.koin.compose.koinInject
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VeranstalterAnlegenWizard(
onCancel: () -> Unit,
onVereinCreated: (Long) -> Unit
) {
var step by remember { mutableStateOf(1) }
var selectedVereinId by remember { mutableLongStateOf(0L) }
val znsImporter = koinInject<ZnsImportProvider>()
val znsState = znsImporter.state
Column(Modifier.fillMaxSize().padding(24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onCancel) { Icon(Icons.Default.Close, null) }
Text("Veranstalter registrieren", style = MaterialTheme.typography.headlineSmall)
}
LinearProgressIndicator(
progress = { if (step == 1) 0.5f else 1.0f },
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)
)
Box(Modifier.weight(1f)) {
when (step) {
1 -> Step1Veranstalter(
znsState = znsState,
znsImporter = znsImporter,
selectedVereinId = selectedVereinId,
onVereinSelected = { selectedVereinId = it },
onVeranstalterCreated = {
selectedVereinId = it
onVereinCreated(it)
}
)
2 -> { /* Optional: Weitere Details für den Veranstalter */ }
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onCancel) { Text("Abbrechen") }
Spacer(Modifier.width(8.dp))
if (step == 1) {
Button(
onClick = { onVereinCreated(selectedVereinId) },
enabled = selectedVereinId != 0L
) {
Text("Fertigstellen")
}
}
}
}
}
@Composable
fun Step1Veranstalter(
znsState: ZnsImportState,
znsImporter: ZnsImportProvider,
selectedVereinId: Long,
onVereinSelected: (Long) -> Unit,
onVeranstalterCreated: (Long) -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
val allVereine = Store.vereine
val filteredVereine = remember(allVereine, searchQuery) {
if (searchQuery.isBlank()) emptyList()
else allVereine.filter {
it.name.contains(searchQuery, ignoreCase = true) ||
it.oepsNummer.contains(searchQuery, ignoreCase = true)
}.take(20)
}
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"Suchen Sie den Verein in den Stammdaten oder importieren Sie eine aktuelle ZNS-Datei.",
style = MaterialTheme.typography.bodyMedium
)
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text("Verein suchen (Name oder OEBS-Nr)") },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Search, null) }
)
if (filteredVereine.isNotEmpty()) {
LazyColumn(Modifier.heightIn(max = 300.dp)) {
items(filteredVereine) { verein ->
ListItem(
headlineContent = { Text(verein.name) },
supportingContent = { Text("OEBS: ${verein.oepsNummer} | ${verein.ort ?: ""}") },
modifier = Modifier.clickable { onVereinSelected(verein.id) },
trailingContent = {
if (selectedVereinId == verein.id) {
Icon(Icons.Default.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
}
}
)
HorizontalDivider()
}
}
} else if (searchQuery.isNotBlank()) {
Text("Kein Verein gefunden.", style = MaterialTheme.typography.bodySmall, color = Color.Gray)
}
}
}
ZnsImportWizardSection(
state = znsState,
onFileSelect = { znsImporter.onFileSelected(it) },
onStartImport = { znsImporter.startImport() },
onReset = { znsImporter.reset() }
)
}
}
@Composable
fun ZnsImportWizardSection(
state: ZnsImportState,
onFileSelect: (String) -> Unit,
onStartImport: () -> Unit,
onReset: () -> Unit
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f))
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.CloudDownload, null, tint = MaterialTheme.colorScheme.tertiary)
Text("ZNS-Stammdaten Import (ZIP/DAT)", style = MaterialTheme.typography.titleSmall)
}
if (state.jobId == null) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = state.selectedFilePath ?: "",
onValueChange = {},
readOnly = true,
placeholder = { Text("Keine Datei gewählt") },
modifier = Modifier.weight(1f),
singleLine = true,
textStyle = MaterialTheme.typography.bodySmall
)
Button(
onClick = {
val path = pickZnsFile()
if (path != null) onFileSelect(path)
},
enabled = !state.isUploading
) {
Icon(Icons.Default.FolderOpen, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Durchsuchen")
}
}
Button(
onClick = onStartImport,
enabled = state.selectedFilePath != null && !state.isUploading,
modifier = Modifier.fillMaxWidth()
) {
if (state.isUploading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Bolt, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Schnell-Import starten (LIGHT)")
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(state.jobStatus ?: "Verarbeite...", style = MaterialTheme.typography.labelMedium)
Text("${state.progress}%", style = MaterialTheme.typography.labelMedium)
}
LinearProgressIndicator(
progress = { state.progress / 100f },
modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)),
)
if (state.isFinished) {
Button(onClick = onReset, modifier = Modifier.align(Alignment.End)) { Text("Neu starten") }
}
}
}
if (state.errorMessage != null) {
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall)
}
}
}
}