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
@@ -6,8 +6,12 @@ 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.repository.MasterdataRepository
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.domain.zns.ZnsImportState
import at.mocode.frontend.core.domain.zns.ZnsRemoteFunktionaer
import at.mocode.frontend.core.domain.zns.ZnsRemotePferd
import at.mocode.frontend.core.domain.zns.ZnsRemoteReiter
import at.mocode.frontend.core.domain.zns.ZnsRemoteVerein
import at.mocode.frontend.core.network.NetworkConfig
import io.ktor.client.*
@@ -44,6 +48,34 @@ internal data class VereinRemoteDto(
val bundesland: String? = null,
)
@Serializable
internal data class ReiterRemoteDto(
val reiterId: String,
val satznummer: String? = null,
val nachname: String,
val vorname: String,
val reiterLizenz: String? = null,
val lizenzKlasse: String,
)
@Serializable
internal data class HorseRemoteDto(
val pferdId: String,
val kopfnummer: String? = null,
val pferdeName: String,
val lebensnummer: String? = null,
val geschlecht: String,
)
@Serializable
internal data class FunktionaerRemoteDto(
val funktionaerId: String,
val satzId: String,
val satzNummer: Int,
val name: String? = null,
val qualifikationen: List<String> = emptyList(),
)
private val TERMINAL_STATES = setOf("ABGESCHLOSSEN", "FEHLER")
private const val POLLING_INTERVAL_MS = 2000L
private const val MAX_VISIBLE_ERRORS = 50
@@ -51,6 +83,7 @@ private const val MAX_VISIBLE_ERRORS = 50
class ZnsImportViewModel(
private val httpClient: HttpClient,
private val authTokenManager: AuthTokenManager,
private val repository: MasterdataRepository,
) : ViewModel(), ZnsImportProvider {
override var state by mutableStateOf(ZnsImportState())
@@ -81,6 +114,7 @@ class ZnsImportViewModel(
jobId = null, progress = 0, progressDetail = "", errors = emptyList()
)
try {
println("[ZNS] Starte Import Mode=$mode Datei=${file.absolutePath}")
val token = authTokenManager.authState.value.token
val response: HttpResponse = httpClient.post("${NetworkConfig.baseUrl}/api/v1/import/zns") {
parameter("mode", mode)
@@ -94,15 +128,31 @@ class ZnsImportViewModel(
})
}))
}
println("[ZNS] Upload Response: ${response.status}")
if (response.status == HttpStatusCode.Accepted) {
val body = json.decodeFromString<ImportStartResponse>(response.bodyAsText())
val responseText = response.bodyAsText()
println("[DEBUG_LOG] Import Started Response: $responseText")
val body = try {
json.decodeFromString<ImportStartResponse>(responseText)
} catch (e: Exception) {
println("[DEBUG_LOG] JSON Decoding failed (Import Start): ${e.message}")
throw Exception("Fehler beim Starten des Imports (Server-Antwort ungültig).")
}
state = state.copy(isUploading = false, jobId = body.jobId, jobStatus = "AUSSTEHEND")
startPolling(body.jobId)
} else {
state = state.copy(isUploading = false, errorMessage = "Upload fehlgeschlagen: HTTP ${response.status.value}")
val errorText = try { response.bodyAsText() } catch (e: Exception) { "Keine Fehlerdetails verfügbar" }
println("[ZNS] Upload Fehler: ${response.status} -> $errorText")
state = state.copy(isUploading = false, errorMessage = "Upload fehlgeschlagen: HTTP ${response.status.value} ($errorText)")
}
} catch (e: Exception) {
state = state.copy(isUploading = false, errorMessage = "Fehler beim Upload: ${e.message}")
println("[ZNS] Exception beim Upload: ${e.message}")
e.printStackTrace()
val displayMessage = when {
e.message?.contains("Connect") == true -> "Verbindung zum Server fehlgeschlagen. Ist das Backend gestartet?"
else -> e.message ?: "Unbekannter Fehler beim Upload"
}
state = state.copy(isUploading = false, errorMessage = displayMessage)
}
}
}
@@ -145,42 +195,80 @@ class ZnsImportViewModel(
}
}
override fun syncFromCloud(onResult: (List<ZnsRemoteVerein>) -> Unit) {
override fun syncFromCloud(onResult: (
List<ZnsRemoteVerein>,
List<ZnsRemoteReiter>,
List<ZnsRemotePferd>,
List<ZnsRemoteFunktionaer>
) -> Unit) {
viewModelScope.launch {
state = state.copy(isSyncing = true, errorMessage = null)
try {
println("[ZNS] Starte Cloud-Sync")
val token = authTokenManager.authState.value.token
// Wir laden die Top 1000 Vereine für den Sync (einfache Implementierung)
val response: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/verein") {
// 1. Vereine
val vResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/verein") {
parameter("limit", 1000)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
}
if (response.status.isSuccess()) {
val results = json.decodeFromString<List<VereinRemoteDto>>(response.bodyAsText())
val domainResults = results.map {
val vResults = if (vResponse.status.isSuccess()) {
json.decodeFromString<List<VereinRemoteDto>>(vResponse.bodyAsText()).map {
ZnsRemoteVerein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.bundesland)
}
} else emptyList()
val now = java.time.LocalDateTime.now()
val version = now.format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))
state = state.copy(
isSyncing = false,
lastSyncVersion = version,
isFinished = true
)
onResult(domainResults)
} else if (response.status == HttpStatusCode.Unauthorized) {
state = state.copy(
isSyncing = false,
errorMessage = "Nicht autorisiert (HTTP 401). Bitte prüfen Sie Ihren Sicherheitsschlüssel im Setup."
)
} else {
state = state.copy(isSyncing = false, errorMessage = "Sync fehlgeschlagen: HTTP ${response.status.value}")
// 2. Reiter
val rResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/reiter") {
parameter("limit", 1000)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
}
val rResults = if (rResponse.status.isSuccess()) {
json.decodeFromString<List<ReiterRemoteDto>>(rResponse.bodyAsText()).map {
ZnsRemoteReiter(it.reiterId, it.satznummer, it.nachname, it.vorname, it.reiterLizenz, it.lizenzKlasse)
}
} else emptyList()
// 3. Pferde
val pResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/horse") {
parameter("limit", 1000)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
}
val pResults = if (pResponse.status.isSuccess()) {
json.decodeFromString<List<HorseRemoteDto>>(pResponse.bodyAsText()).map {
ZnsRemotePferd(it.pferdId, it.kopfnummer, it.pferdeName, it.lebensnummer, it.geschlecht)
}
} else emptyList()
// 4. Funktionäre
val fResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/funktionaer") {
parameter("limit", 1000)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
}
val fResults = if (fResponse.status.isSuccess()) {
json.decodeFromString<List<FunktionaerRemoteDto>>(fResponse.bodyAsText()).map {
ZnsRemoteFunktionaer(it.funktionaerId, it.satzId, it.satzNummer, it.name, it.qualifikationen)
}
} else emptyList()
val now = java.time.LocalDateTime.now()
val version = now.format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))
state = state.copy(
isSyncing = false,
lastSyncVersion = version,
isFinished = true
)
onResult(vResults, rResults, pResults, fResults)
} catch (e: Exception) {
state = state.copy(isSyncing = false, errorMessage = "Fehler beim Cloud-Sync: ${e.message}")
println("[ZNS] Exception beim Sync: ${e.message}")
e.printStackTrace()
val displayMessage = when {
e.message?.contains("Connect") == true -> "Verbindung zum Server fehlgeschlagen. Ist das Backend gestartet?"
else -> e.message ?: "Unbekannter Fehler beim Cloud-Sync"
}
state = state.copy(isSyncing = false, errorMessage = displayMessage)
}
}
}
@@ -195,7 +283,13 @@ class ZnsImportViewModel(
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
}
if (response.status.isSuccess()) {
val status = json.decodeFromString<JobStatusResponse>(response.bodyAsText())
val responseText = response.bodyAsText()
val status = try {
json.decodeFromString<JobStatusResponse>(responseText)
} catch (e: Exception) {
println("[DEBUG_LOG] Polling JSON Decoding failed: ${e.message}")
throw Exception("Status-Format ungültig.")
}
state = state.copy(
jobStatus = status.status,
progress = status.fortschritt,
@@ -204,9 +298,12 @@ class ZnsImportViewModel(
isFinished = status.status in TERMINAL_STATES,
)
if (status.status in TERMINAL_STATES) break
} else {
println("[ZNS] Polling Fehler: ${response.status}")
}
} catch (e: Exception) {
state = state.copy(errorMessage = "Polling-Fehler: ${e.message}", isFinished = true)
println("[ZNS] Polling Exception: ${e.message}")
state = state.copy(errorMessage = "Status-Abfrage fehlgeschlagen: ${e.message}", isFinished = true)
break
}
delay(POLLING_INTERVAL_MS.milliseconds)
@@ -214,6 +311,19 @@ class ZnsImportViewModel(
}
}
override fun addSyncResults(
vereine: List<ZnsRemoteVerein>,
reiter: List<ZnsRemoteReiter>,
pferde: List<ZnsRemotePferd>,
funktionaere: List<ZnsRemoteFunktionaer>
) {
println("[ZNS] Sync-Ergebnisse empfangen: ${vereine.size} V, ${reiter.size} R, ${pferde.size} P, ${funktionaere.size} F")
repository.saveVereine(vereine)
repository.saveReiter(reiter)
repository.savePferde(pferde)
repository.saveFunktionaere(funktionaere)
}
override fun reset() {
pollingJob?.cancel()
state = ZnsImportState()
@@ -6,6 +6,6 @@ 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()) }
factory<ZnsImportProvider> { ZnsImportViewModel(get(named("apiClient")), get(), get()) }
factory { ZnsImportViewModel(get(named("apiClient")), get(), get()) }
}
@@ -19,17 +19,22 @@ import org.koin.compose.viewmodel.koinViewModel
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@Composable
fun StammdatenImportScreen(
viewModel: ZnsImportViewModel = koinViewModel(),
onBack: () -> Unit,
) {
val state = viewModel.state
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
.padding(24.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Titel
@@ -56,14 +61,20 @@ fun StammdatenImportScreen(
value = state.selectedFilePath ?: "",
onValueChange = {},
readOnly = true,
placeholder = { Text("Keine Datei ausgewählt…") },
placeholder = { Text("ZNS-Datei auswählen (.zip, .dat)...") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Button(
onClick = {
val path = pickZipFile()
if (path != null) viewModel.onFileSelected(path)
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)
if (result == JFileChooser.APPROVE_OPTION) {
viewModel.onFileSelected(chooser.selectedFile.absolutePath)
}
},
enabled = !state.isUploading && !(!state.isFinished && state.jobId != null),
) {
@@ -99,10 +110,65 @@ fun StammdatenImportScreen(
}
}
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Cloud-Synchronisation", style = MaterialTheme.typography.titleMedium)
Text(
"Stammdaten direkt vom OEPS-Server laden",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(
onClick = {
viewModel.syncFromCloud { vereine, reiter, pferde, funktionaere ->
println("[ZNS] Sync Abschluss: ${vereine.size} V, ${reiter.size} R, ${pferde.size} P, ${funktionaere.size} F")
viewModel.addSyncResults(vereine, reiter, pferde, funktionaere)
}
},
enabled = !state.isSyncing,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
) {
if (state.isSyncing) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.CloudSync, contentDescription = null)
}
Spacer(Modifier.width(8.dp))
Text("Cloud-Sync")
}
}
}
}
// Fehler-Banner
// Cloud-Sync Status
if (state.lastSyncVersion != null) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.Info, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Text(
"Letzter erfolgreicher Cloud-Sync: ${state.lastSyncVersion}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
if (state.errorMessage != null) {
Card(
modifier = Modifier.fillMaxWidth(),
@@ -208,7 +274,7 @@ fun StammdatenImportScreen(
// Fehler-Liste
if (state.errors.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth().weight(1f),
modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -229,25 +295,33 @@ fun StammdatenImportScreen(
)
}
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),
)
Box(modifier = Modifier.weight(1f)) {
val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
LazyColumn(
state = lazyListState,
modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp),
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),
)
}
}
}
androidx.compose.foundation.VerticalScrollbar(
adapter = androidx.compose.foundation.rememberScrollbarAdapter(lazyListState),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight()
)
}
}
}
@@ -280,13 +354,3 @@ private fun StatusChip(status: String?) {
)
}
}
/** Ö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
}