feat(veranstaltung): Wizard für neue Veranstaltung implementiert und ZNS-Light-Integration hinzugefügt

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-16 13:26:39 +02:00
parent 10f9e82718
commit cb4f2f855c
11 changed files with 318 additions and 121 deletions
@@ -0,0 +1,20 @@
package at.mocode.frontend.core.domain.zns
data class ZnsImportState(
val selectedFilePath: String? = null,
val isUploading: Boolean = false,
val jobId: String? = null,
val jobStatus: String? = null,
val progress: Int = 0,
val progressDetail: String = "",
val errors: List<String> = emptyList(),
val errorMessage: String? = null,
val isFinished: Boolean = false,
)
interface ZnsImportProvider {
val state: ZnsImportState
fun onFileSelected(path: String)
fun startImport(mode: String = "FULL")
fun reset()
}
@@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
/**
* Feature-Modul: Veranstaltungs-Verwaltung (Desktop-only)
* Kapselt alle Screens und Logik für Veranstaltungs-Übersicht, -Detail und -Neuanlage.
* kapselt alle Screens und Logik für Veranstaltungs-Übersicht, -Detail und -Neuanlage.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
@@ -40,6 +40,7 @@ kotlin {
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.domain)
implementation(projects.core.coreDomain)
implementation(projects.frontend.core.auth)
implementation(compose.foundation)
implementation(compose.runtime)
@@ -13,6 +13,26 @@ import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsButton
import at.mocode.frontend.core.designsystem.components.MsTextField
import at.mocode.frontend.core.designsystem.theme.Dimens
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
enum class MsWizardStep {
ZNS_BASIS,
META_DATEN,
FACHLICHER_TYP
}
private fun pickZipFile(): String? {
val chooser = JFileChooser()
val filter = FileNameExtensionFilter("ZNS ZIP Datei", "zip")
chooser.fileFilter = filter
val returnVal = chooser.showOpenDialog(null)
return if (returnVal == JFileChooser.APPROVE_OPTION) {
chooser.selectedFile.absolutePath
} else {
null
}
}
/**
* Formular zum Anlegen einer neuen Veranstaltung (Vision_03: /veranstaltung/neu).
@@ -23,24 +43,17 @@ fun VeranstaltungNeuScreen(
onBack: () -> Unit,
onSave: () -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(0) } // Stammdaten ist Standard-Tab
val tabs = listOf("Stammdaten (A-Satz)", "Organisation", "Preisliste")
var currentStep by remember { mutableStateOf(MsWizardStep.ZNS_BASIS) }
// Formular-State für Stammdaten
// Formular-State
var name by remember { mutableStateOf("") }
var ort by remember { mutableStateOf("") }
var startDatum by remember { mutableStateOf("") }
var endDatum by remember { mutableStateOf("") }
var veranstalter by remember { mutableStateOf("") }
// Validierung
val isNameValid = name.isNotBlank()
val isOrtValid = ort.isNotBlank()
val isStartDatumValid = startDatum.length >= 8 // Einfache Prüfung für DD.MM.YY
val isFormValid = isNameValid && isOrtValid && isStartDatumValid
var veranstalterId by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar (Header)
// Wizard Header
Surface(
modifier = Modifier.fillMaxWidth(),
shadowElevation = 2.dp,
@@ -52,33 +65,58 @@ fun VeranstaltungNeuScreen(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
IconButton(onClick = onBack) {
IconButton(onClick = {
if (currentStep == MsWizardStep.ZNS_BASIS) onBack()
else currentStep = MsWizardStep.entries[currentStep.ordinal - 1]
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(Dimens.SpacingS))
Text(
text = "Neue Veranstaltung anlegen",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Column {
Text(
text = "Neue Veranstaltung anlegen",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = when (currentStep) {
MsWizardStep.ZNS_BASIS -> "Schritt 1: ZNS-Basis & Veranstalter"
MsWizardStep.META_DATEN -> "Schritt 2: Meta-Daten & DB-Initialisierung"
MsWizardStep.FACHLICHER_TYP -> "Schritt 3: Fachlicher Typ"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
if (currentStep != MsWizardStep.FACHLICHER_TYP) {
MsButton(
text = "Weiter",
onClick = { currentStep = MsWizardStep.entries[currentStep.ordinal + 1] },
enabled = when (currentStep) {
MsWizardStep.ZNS_BASIS -> true // Vereinfacht für Prototypen
MsWizardStep.META_DATEN -> name.isNotBlank() && ort.isNotBlank()
else -> true
}
)
} else {
MsButton(
text = "Veranstaltung finalisieren",
onClick = onSave,
enabled = true
)
}
}
MsButton(
text = "Veranstaltung speichern",
onClick = onSave,
enabled = isFormValid
)
}
}
PrimaryTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
)
}
}
// Step Indicator
LinearProgressIndicator(
progress = { (currentStep.ordinal + 1).toFloat() / MsWizardStep.entries.size.toFloat() },
modifier = Modifier.fillMaxWidth(),
)
Box(
modifier = Modifier
@@ -86,88 +124,69 @@ fun VeranstaltungNeuScreen(
.padding(Dimens.SpacingL)
.verticalScroll(rememberScrollState())
) {
when (selectedTab) {
0 -> { // Stammdaten
when (currentStep) {
MsWizardStep.ZNS_BASIS -> {
Column(
modifier = Modifier.widthIn(max = 800.dp),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
) {
Text("ZNS-Daten importieren (Light-Modus)", style = MaterialTheme.typography.titleLarge)
Text("Invertierter Workflow: Zuerst ZNS-Basisdaten laden, dann Veranstaltung anlegen.")
MsButton(
text = "ZNS.zip auswählen & importieren",
onClick = { pickZipFile() }
)
MsTextField(
value = veranstalterId,
onValueChange = { veranstalterId = it },
label = "Veranstalter (aus importierten Vereinen)",
placeholder = "Verein suchen..."
)
}
}
MsWizardStep.META_DATEN -> {
Column(
modifier = Modifier.widthIn(max = 600.dp),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
) {
Text(
"Allgemeine Informationen (A-Satz)",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
MsTextField(
value = name,
onValueChange = { name = it },
label = "Name der Veranstaltung (z.B. Pfingstturnier)",
placeholder = "Name eingeben",
isError = name.isBlank(),
errorMessage = if (name.isBlank()) "Name ist erforderlich" else null
)
MsTextField(
value = veranstalter,
onValueChange = { veranstalter = it },
label = "Veranstalter (Verein)",
placeholder = "Verein suchen oder eingeben"
)
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
MsTextField(
value = ort,
onValueChange = { ort = it },
label = "Ort",
modifier = Modifier.weight(1f),
isError = ort.isBlank()
)
MsTextField(
value = "Österreich",
onValueChange = {},
label = "Land",
modifier = Modifier.weight(0.5f),
readOnly = true
)
}
Text("Veranstaltungs-Details", style = MaterialTheme.typography.titleLarge)
MsTextField(value = name, onValueChange = { name = it }, label = "Name")
MsTextField(value = ort, onValueChange = { ort = it }, label = "Ort")
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
MsTextField(
value = startDatum,
onValueChange = { startDatum = it },
label = "Startdatum (TT.MM.JJJJ)",
placeholder = "24.04.2026",
label = "Start",
modifier = Modifier.weight(1f)
)
MsTextField(
value = endDatum,
onValueChange = { endDatum = it },
label = "Enddatum (TT.MM.JJJJ)",
placeholder = "26.04.2026",
label = "Ende",
modifier = Modifier.weight(1f)
)
}
Text(
"Hinweis: Diese Daten bilden die Basis für den ZNS-Import und die Abrechnung.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
1 -> { // Organisation
Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
Text("Funktionäre & Verantwortliche", style = MaterialTheme.typography.titleLarge)
Text("Hier werden Richter, Parcourschefs und Tierärzte zugewiesen.")
// Später: MsSearchableSelect für Funktionäre
}
}
2 -> { // Preisliste
Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
Text("Gebühren & Preisliste", style = MaterialTheme.typography.titleLarge)
Text("Definition der Nenngebühren und Pauschalen gemäß ÖTO.")
MsWizardStep.FACHLICHER_TYP -> {
Column(
modifier = Modifier.widthIn(max = 600.dp),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
) {
Text("Typ der Veranstaltung", style = MaterialTheme.typography.titleLarge)
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(Dimens.SpacingM),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Text("🏆 Turnier", modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium)
RadioButton(selected = true, onClick = null)
}
}
}
}
}
@@ -0,0 +1,124 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.core.domain.serialization.UuidSerializer
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.network.NetworkConfig
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
enum class WizardStep {
ZNS_IMPORT,
META_DATA,
TYPE_SELECTION
}
@OptIn(ExperimentalUuidApi::class)
data class VeranstaltungWizardState(
val currentStep: WizardStep = WizardStep.ZNS_IMPORT,
val veranstalterId: Uuid? = null,
val name: String = "",
val ort: String = "",
val startDatum: LocalDate? = null,
val endDatum: LocalDate? = null,
val isSaving: Boolean = false,
val error: String? = null,
val createdVeranstaltungId: Uuid? = null
)
@OptIn(ExperimentalUuidApi::class)
class VeranstaltungWizardViewModel(
private val httpClient: HttpClient,
private val authTokenManager: AuthTokenManager,
val znsViewModel: ZnsImportProvider
) : ViewModel() {
var state by mutableStateOf(VeranstaltungWizardState())
private set
fun nextStep() {
state = state.copy(
currentStep = when (state.currentStep) {
WizardStep.ZNS_IMPORT -> WizardStep.META_DATA
WizardStep.META_DATA -> WizardStep.TYPE_SELECTION
WizardStep.TYPE_SELECTION -> WizardStep.TYPE_SELECTION
}
)
}
fun previousStep() {
state = state.copy(
currentStep = when (state.currentStep) {
WizardStep.ZNS_IMPORT -> WizardStep.ZNS_IMPORT
WizardStep.META_DATA -> WizardStep.ZNS_IMPORT
WizardStep.TYPE_SELECTION -> WizardStep.META_DATA
}
)
}
fun updateMetaData(name: String, ort: String, start: LocalDate?, end: LocalDate?) {
state = state.copy(name = name, ort = ort, startDatum = start, endDatum = end)
}
fun setVeranstalter(id: Uuid) {
state = state.copy(veranstalterId = id)
}
fun saveVeranstaltung() {
val veranstalterId = state.veranstalterId ?: return
val start = state.startDatum ?: return
val end = state.endDatum ?: return
viewModelScope.launch {
state = state.copy(isSaving = true, error = null)
try {
val token = authTokenManager.authState.value.token
val response = httpClient.post("${NetworkConfig.baseUrl}/api/events") {
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
contentType(ContentType.Application.Json)
setBody(
CreateEventRequest(
name = state.name,
startDatum = start,
endDatum = end,
ort = state.ort,
veranstalterVereinId = veranstalterId
)
)
}
if (response.status == HttpStatusCode.Created) {
// Hier müsste die ID aus dem Response gelesen werden, falls benötigt
state = state.copy(isSaving = false)
nextStep()
} else {
state = state.copy(isSaving = false, error = "Fehler beim Speichern: ${response.status}")
}
} catch (e: Exception) {
state = state.copy(isSaving = false, error = "Netzwerkfehler: ${e.message}")
}
}
}
}
@Serializable
@OptIn(ExperimentalUuidApi::class)
data class CreateEventRequest(
val name: String,
val startDatum: LocalDate,
val endDatum: LocalDate,
val ort: String,
@Serializable(with = UuidSerializer::class)
val veranstalterVereinId: Uuid
)
@@ -6,6 +6,8 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.domain.zns.ZnsImportState
import at.mocode.frontend.core.network.NetworkConfig
import io.ktor.client.*
import io.ktor.client.request.*
@@ -20,17 +22,6 @@ import kotlinx.serialization.json.Json
import java.io.File
import kotlin.time.Duration.Companion.milliseconds
data class ZnsImportState(
val selectedFilePath: String? = null,
val isUploading: Boolean = false,
val jobId: String? = null,
val jobStatus: String? = null,
val progress: Int = 0,
val progressDetail: String = "",
val errors: List<String> = emptyList(),
val errorMessage: String? = null,
val isFinished: Boolean = false,
)
@Serializable
internal data class JobIdResponse(val jobId: String)
@@ -51,19 +42,19 @@ private const val MAX_VISIBLE_ERRORS = 50
class ZnsImportViewModel(
private val httpClient: HttpClient,
private val authTokenManager: AuthTokenManager,
) : ViewModel() {
) : ViewModel(), ZnsImportProvider {
var state by mutableStateOf(ZnsImportState())
override var state by mutableStateOf(ZnsImportState())
private set
private var pollingJob: Job? = null
private val json = Json { ignoreUnknownKeys = true }
fun onFileSelected(path: String) {
override fun onFileSelected(path: String) {
state = ZnsImportState(selectedFilePath = path)
}
fun startImport() {
override fun startImport(mode: String) {
val filePath = state.selectedFilePath ?: return
val file = File(filePath)
if (!file.exists() || !file.name.endsWith(".zip", ignoreCase = true)) {
@@ -79,6 +70,7 @@ class ZnsImportViewModel(
try {
val token = authTokenManager.authState.value.token
val response: HttpResponse = httpClient.post("${NetworkConfig.baseUrl}/api/v1/import/zns") {
parameter("mode", mode)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
setBody(MultiPartFormDataContent(formData {
append("file", file.readBytes(), Headers.build {
@@ -129,7 +121,7 @@ class ZnsImportViewModel(
}
}
fun reset() {
override fun reset() {
pollingJob?.cancel()
state = ZnsImportState()
}
@@ -1,9 +1,11 @@
package at.mocode.zns.feature.di
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.zns.feature.ZnsImportViewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
val znsImportModule = module {
factory<ZnsImportProvider> { ZnsImportViewModel(get(named("apiClient")), get()) }
factory { ZnsImportViewModel(get(named("apiClient")), get()) }
}
@@ -114,7 +114,10 @@ fun StammdatenImportScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Text(state.errorMessage, color = MaterialTheme.colorScheme.onErrorContainer)
val errorMsg = state.errorMessage
if (errorMsg != null) {
Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer)
}
}
}
}
@@ -129,7 +132,10 @@ fun StammdatenImportScreen(
verticalAlignment = Alignment.CenterVertically,
) {
Text("Status", style = MaterialTheme.typography.titleMedium)
StatusChip(state.jobStatus)
val statusMsg = state.jobStatus
if (statusMsg != null) {
StatusChip(statusMsg)
}
}
LinearProgressIndicator(