chore: entferne nicht genutzte NennungsMaske-Komponente, extrahiere AktionsButtonLeiste in separaten Komponentenordner
This commit is contained in:
+139
-29
@@ -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()
|
||||
|
||||
+2
-2
@@ -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()) }
|
||||
}
|
||||
|
||||
+97
-33
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user