Add audit logging for Bewerb changes, implement ZNS B-Satz export, enhance Zeitplan tab with audit log display, export dialog, and clickable Bewerb items, and integrate FixedWidthLineBuilder utility.
This commit is contained in:
+12
@@ -25,12 +25,24 @@ data class AbteilungsWarnung(
|
||||
val oetoParagraph: String?
|
||||
)
|
||||
|
||||
data class AuditLogEntry(
|
||||
val id: String,
|
||||
val entityType: String,
|
||||
val entityId: String,
|
||||
val action: String,
|
||||
val userId: String?,
|
||||
val timestamp: String,
|
||||
val changesJson: String?
|
||||
)
|
||||
|
||||
interface BewerbRepository {
|
||||
suspend fun list(turnierId: Long): Result<List<Bewerb>>
|
||||
suspend fun getById(id: Long): Result<Bewerb>
|
||||
suspend fun create(model: Bewerb): Result<Bewerb>
|
||||
suspend fun update(id: Long, model: Bewerb): Result<Bewerb>
|
||||
suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb>
|
||||
suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>>
|
||||
suspend fun exportZnsBSatz(turnierId: Long): Result<String>
|
||||
suspend fun delete(id: Long): Result<Unit>
|
||||
suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit>
|
||||
}
|
||||
|
||||
+33
@@ -44,6 +44,11 @@ data class BewerbState(
|
||||
val currentStartliste: List<StartlistenZeile> = emptyList(),
|
||||
val discoveredNodes: List<DiscoveredService> = emptyList(),
|
||||
val isScanning: Boolean = false,
|
||||
// Zeitplan-Audit
|
||||
val auditLog: List<at.mocode.turnier.feature.domain.AuditLogEntry> = emptyList(),
|
||||
val isAuditLoading: Boolean = false,
|
||||
val exportContent: String? = null,
|
||||
val showExportDialog: Boolean = false,
|
||||
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
|
||||
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
|
||||
)
|
||||
@@ -71,6 +76,9 @@ sealed interface BewerbIntent {
|
||||
data object StopNetworkScan : BewerbIntent
|
||||
data object RefreshDiscoveredNodes : BewerbIntent
|
||||
data class UpdateZeitplan(val id: Long, val beginnZeit: String?) : BewerbIntent
|
||||
data class LoadAuditLog(val bewerbId: Long) : BewerbIntent
|
||||
data object ExportZnsBSatz : BewerbIntent
|
||||
data object CloseExportDialog : BewerbIntent
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +170,31 @@ class BewerbViewModel(
|
||||
is BewerbIntent.StopNetworkScan -> stopScan()
|
||||
is BewerbIntent.RefreshDiscoveredNodes -> refreshNodes()
|
||||
is BewerbIntent.UpdateZeitplan -> updateZeitplan(intent.id, intent.beginnZeit)
|
||||
is BewerbIntent.LoadAuditLog -> loadAuditLog(intent.bewerbId)
|
||||
is BewerbIntent.ExportZnsBSatz -> exportZnsBSatz()
|
||||
is BewerbIntent.CloseExportDialog -> reduce { it.copy(showExportDialog = false, exportContent = null) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportZnsBSatz() {
|
||||
_state.update { it.copy(isLoading = true) }
|
||||
scope.launch {
|
||||
repo.exportZnsBSatz(turnierId).onSuccess { content ->
|
||||
_state.update { it.copy(isLoading = false, showExportDialog = true, exportContent = content) }
|
||||
}.onFailure { t ->
|
||||
_state.update { it.copy(isLoading = false, errorMessage = "ZNS-Export fehlgeschlagen: ${t.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAuditLog(id: Long) {
|
||||
_state.update { it.copy(isAuditLoading = true) }
|
||||
scope.launch {
|
||||
repo.getAuditLog(id).onSuccess { log ->
|
||||
_state.update { it.copy(auditLog = log, isAuditLoading = false) }
|
||||
}.onFailure { t ->
|
||||
_state.update { it.copy(isAuditLoading = false, errorMessage = "Audit-Log konnte nicht geladen werden: ${t.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
-20
@@ -4,6 +4,7 @@ import at.mocode.frontend.core.network.*
|
||||
import at.mocode.turnier.feature.data.mapper.toDomain
|
||||
import at.mocode.turnier.feature.data.mapper.toDto
|
||||
import at.mocode.turnier.feature.data.remote.dto.BewerbDto
|
||||
import at.mocode.turnier.feature.domain.AuditLogEntry
|
||||
import at.mocode.turnier.feature.domain.Bewerb
|
||||
import at.mocode.turnier.feature.domain.BewerbRepository
|
||||
import io.ktor.client.*
|
||||
@@ -26,18 +27,19 @@ class DefaultBewerbRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit> = runCatching {
|
||||
val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") {
|
||||
setBody(bewerbe)
|
||||
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit> =
|
||||
runCatching {
|
||||
val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") {
|
||||
setBody(bewerbe)
|
||||
}
|
||||
when {
|
||||
response.status.isSuccess() -> Unit
|
||||
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
|
||||
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
when {
|
||||
response.status.isSuccess() -> Unit
|
||||
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
|
||||
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getById(id: Long): Result<Bewerb> = runCatching {
|
||||
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id")
|
||||
@@ -72,23 +74,47 @@ class DefaultBewerbRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> = runCatching {
|
||||
val response = client.patch("${ApiRoutes.API_PREFIX}/bewerbe/$id/zeitplan") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(mapOf(
|
||||
"geplantesDatum" to datum,
|
||||
"beginnZeit" to beginn,
|
||||
"austragungsplatzId" to platzId
|
||||
))
|
||||
override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> =
|
||||
runCatching {
|
||||
val response = client.patch("${ApiRoutes.API_PREFIX}/bewerbe/$id/zeitplan") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
mapOf(
|
||||
"geplantesDatum" to datum,
|
||||
"beginnZeit" to beginn,
|
||||
"austragungsplatzId" to platzId
|
||||
)
|
||||
)
|
||||
}
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
|
||||
response.status == HttpStatusCode.NotFound -> throw NotFound()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>> = runCatching {
|
||||
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/audit-log")
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
|
||||
response.status.isSuccess() -> response.body<List<AuditLogEntry>>()
|
||||
response.status == HttpStatusCode.NotFound -> throw NotFound()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun exportZnsBSatz(turnierId: Long): Result<String> = runCatching {
|
||||
val response = client.get("${ApiRoutes.API_PREFIX}/turniere/$turnierId/export/zns/b-satz")
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<String>()
|
||||
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
|
||||
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long): Result<Unit> = runCatching {
|
||||
val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id")
|
||||
when {
|
||||
|
||||
+155
-30
@@ -2,8 +2,11 @@ package at.mocode.turnier.feature.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -67,46 +70,90 @@ fun ZeitplanTabContent(
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
var showAuditLog by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header / Toolbar
|
||||
ZeitplanToolbar()
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Header / Toolbar
|
||||
ZeitplanToolbar(viewModel = viewModel, onShowHistory = { showAuditLog = !showAuditLog })
|
||||
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
// Zeit-Achse (feststehend)
|
||||
ZeitAchse()
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
// Zeit-Achse (feststehend)
|
||||
ZeitAchse()
|
||||
|
||||
// Content (scrollbar)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
// Hintergrund-Gitter
|
||||
ZeitplanGitter()
|
||||
// Content (scrollbar)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
// Hintergrund-Gitter
|
||||
ZeitplanGitter()
|
||||
|
||||
// Bewerbe / Blöcke
|
||||
items.forEach { item ->
|
||||
DraggableBewerbBox(
|
||||
item = item,
|
||||
onPositionChange = { newMinutes ->
|
||||
val h = newMinutes / 60
|
||||
val m = newMinutes % 60
|
||||
val timeStr = "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}"
|
||||
viewModel.send(BewerbIntent.UpdateZeitplan(item.id, timeStr))
|
||||
}
|
||||
)
|
||||
// Bewerbe / Blöcke
|
||||
items.forEach { item ->
|
||||
DraggableBewerbBox(
|
||||
item = item,
|
||||
onPositionChange = { newMinutes ->
|
||||
val h = newMinutes / 60
|
||||
val m = newMinutes % 60
|
||||
val timeStr = "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}"
|
||||
viewModel.send(BewerbIntent.UpdateZeitplan(item.id, timeStr))
|
||||
},
|
||||
onClick = {
|
||||
viewModel.send(BewerbIntent.Select(item.id))
|
||||
viewModel.send(BewerbIntent.LoadAuditLog(item.id))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAuditLog) {
|
||||
VerticalDivider(color = SlotBorder)
|
||||
AuditLogSektion(
|
||||
state = state,
|
||||
modifier = Modifier.width(300.dp).fillMaxHeight()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showExportDialog && state.exportContent != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.send(BewerbIntent.CloseExportDialog) },
|
||||
title = { Text("ZNS B-Satz Export") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Der Export für den ZNS B-Satz wurde generiert. Kopiere den Inhalt in deine n2-Datei.")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val content = state.exportContent ?: ""
|
||||
OutlinedTextField(
|
||||
value = content,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier.fillMaxWidth().height(200.dp),
|
||||
textStyle = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { viewModel.send(BewerbIntent.CloseExportDialog) }) {
|
||||
Text("Schließen")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZeitplanToolbar() {
|
||||
private fun ZeitplanToolbar(
|
||||
viewModel: BewerbViewModel,
|
||||
onShowHistory: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -115,6 +162,10 @@ private fun ZeitplanToolbar() {
|
||||
Text("Zeitplan-Optimierung", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = ZeitplanBlue)
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
TextButton(onClick = onShowHistory) {
|
||||
Text("Historie anzeigen", color = ZeitplanBlue, fontSize = 13.sp)
|
||||
}
|
||||
|
||||
// Platz-Filter (Mock)
|
||||
Text("Platz:", fontSize = 13.sp)
|
||||
AssistChip(onClick = {}, label = { Text("Hauptplatz") })
|
||||
@@ -123,10 +174,82 @@ private fun ZeitplanToolbar() {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = { /* Speichern */ },
|
||||
onClick = { viewModel.send(BewerbIntent.ExportZnsBSatz) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = ZeitplanBlue)
|
||||
) {
|
||||
Text("Änderungen speichern", fontSize = 13.sp)
|
||||
Text("B-Satz Export (ZNS)", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuditLogSektion(
|
||||
state: BewerbState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.background(Color.White).padding(16.dp)) {
|
||||
Text(
|
||||
text = "ÄNDERUNGS-HISTORIE",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = HourLabelColor
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (state.selectedId == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Wähle einen Bewerb aus,\num die Historie zu sehen.", color = HourLabelColor, fontSize = 12.sp)
|
||||
}
|
||||
} else if (state.isAuditLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = ZeitplanBlue)
|
||||
}
|
||||
} else if (state.auditLog.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Änderungen erfasst.", color = HourLabelColor, fontSize = 12.sp)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items(state.auditLog) { entry ->
|
||||
AuditLogItem(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuditLogItem(entry: at.mocode.turnier.feature.domain.AuditLogEntry) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(ZeitplanBg, RoundedCornerShape(4.dp))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(6.dp).background(ZeitplanBlue, RoundedCornerShape(3.dp)))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = entry.action,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Black
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
text = entry.timestamp.split("T").lastOrNull()?.take(5) ?: "",
|
||||
fontSize = 10.sp,
|
||||
color = HourLabelColor
|
||||
)
|
||||
}
|
||||
if (entry.changesJson != null) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = entry.changesJson,
|
||||
fontSize = 10.sp,
|
||||
color = Color.DarkGray,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,7 +300,8 @@ private fun ZeitplanGitter() {
|
||||
@Composable
|
||||
private fun DraggableBewerbBox(
|
||||
item: ZeitplanItemUi,
|
||||
onPositionChange: (Int) -> Unit
|
||||
onPositionChange: (Int) -> Unit,
|
||||
onClick: () -> Unit = {}
|
||||
) {
|
||||
// Berechnung der Position basierend auf den Startminuten seit START_HOUR
|
||||
val relativeMinutes = item.startMinutes - (START_HOUR * 60)
|
||||
@@ -197,6 +321,7 @@ private fun DraggableBewerbBox(
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(item.color.copy(alpha = 0.15f))
|
||||
.border(1.dp, item.color, RoundedCornerShape(6.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragEnd = {
|
||||
|
||||
+2
@@ -123,6 +123,8 @@ fun PreviewTurnierBewerbeTab() {
|
||||
override suspend fun update(id: Long, model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
|
||||
override suspend fun delete(id: Long): Result<Unit> = Result.success(Unit)
|
||||
override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> = Result.failure(NotImplementedError())
|
||||
override suspend fun getAuditLog(bewerbId: Long): Result<List<at.mocode.turnier.feature.domain.AuditLogEntry>> = Result.success(emptyList())
|
||||
override suspend fun exportZnsBSatz(turnierId: Long): Result<String> = Result.success("BBEWERBE\r\n B0100Bewerb 1 A01 20260411001\r\n")
|
||||
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit)
|
||||
}
|
||||
val mockStartlistenRepo = object : StartlistenRepository {
|
||||
|
||||
Reference in New Issue
Block a user