From 92aecf9abf4c254d962024522f8bd9c8e9138ea5 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Sat, 11 Apr 2026 21:23:33 +0200 Subject: [PATCH] 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. --- .../entries/entries-service/build.gradle.kts | 4 +- .../entries/service/bewerbe/BewerbService.kt | 46 ++++- .../service/bewerbe/BewerbeController.kt | 47 +++++ .../core/utils/parser/FixedWidthParser.kt | 32 +++ .../core/utils/database/DatabaseFactory.kt | 4 +- .../at/mocode/zns/parser/ZnsBewerbParser.kt | 33 ++++ .../feature/domain/BewerbRepository.kt | 12 ++ .../feature/presentation/BewerbViewModel.kt | 33 ++++ .../data/remote/DefaultBewerbRepository.kt | 66 +++++-- .../presentation/TurnierZeitplanTab.kt | 185 +++++++++++++++--- .../desktop/screens/preview/ScreenPreviews.kt | 2 + 11 files changed, 402 insertions(+), 62 deletions(-) diff --git a/backend/services/entries/entries-service/build.gradle.kts b/backend/services/entries/entries-service/build.gradle.kts index 2357da14..9e06981c 100644 --- a/backend/services/entries/entries-service/build.gradle.kts +++ b/backend/services/entries/entries-service/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.backend.services.billing.billingService) implementation(projects.core.coreUtils) implementation(projects.core.coreDomain) + implementation(projects.core.znsParser) implementation(projects.backend.infrastructure.monitoring.monitoringClient) implementation(projects.backend.infrastructure.security) @@ -26,7 +27,7 @@ dependencies { implementation(libs.bundles.spring.boot.secure.service) // Common service extras implementation(libs.spring.boot.starter.validation) - // JSON + Web: ensure Spring Web (incl. HttpMessageConverters) is on classpath + // JSON + Web: ensure Spring Web (incl. HttpMessageConverters) is on the classpath implementation("org.springframework.boot:spring-boot-starter-web") implementation(libs.spring.boot.starter.json) implementation(libs.postgresql.driver) @@ -54,6 +55,7 @@ dependencies { // Flyway runtime (provided by BOM, ensure availability in this module) implementation(libs.flyway.core) implementation(libs.flyway.postgresql) + implementation(project(":core:zns-parser")) testImplementation(projects.platform.platformTesting) testImplementation(libs.bundles.testing.jvm) diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt index 4830d365..c41d3bf0 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt @@ -8,13 +8,24 @@ import at.mocode.entries.domain.service.CompetitionWarningService import at.mocode.entries.service.errors.LockedException import at.mocode.entries.service.persistence.AuditLogTable import at.mocode.entries.service.persistence.TurnierTable -import org.jetbrains.exposed.v1.jdbc.insert import at.mocode.entries.service.tenant.tenantTransaction +import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll import kotlin.uuid.Uuid import kotlin.uuid.toJavaUuid +data class AuditLogEntry( + val id: Uuid, + val entityType: String, + val entityId: Uuid, + val action: String, + val userId: Uuid?, + val timestamp: kotlin.time.Instant, + val changesJson: String? +) + class BewerbService( private val repo: BewerbRepository, private val nennungen: NennungRepository, @@ -158,7 +169,7 @@ class BewerbService( suspend fun updateZeitplan(id: Uuid, req: UpdateZeitplanRequest): Bewerb { val current = get(id) - // Hier erlauben wir Änderungen auch wenn PUBLISHED (da Drag & Drop im Live-Betrieb nötig) + // Hier erlauben wir Änderungen, auch wenn PUBLISHED (da Drag & Drop im Live-Betrieb nötig) val updated = current.copy( geplantesDatum = req.geplantesDatum, beginnZeit = req.beginnZeit, @@ -167,18 +178,35 @@ class BewerbService( tenantTransaction { // Audit-Log schreiben - AuditLogTable.insert { - it[entityType] = "BEWERB" - it[entityId] = id.toJavaUuid() - it[action] = "UPDATE_ZEITPLAN" - it[timestamp] = kotlin.time.Clock.System.now() - it[changesJson] = "{\"old\": {\"datum\": \"${current.geplantesDatum}\", \"zeit\": \"${current.beginnZeit}\", \"platz\": \"${current.austragungsplatzId}\"}, \"new\": {\"datum\": \"${updated.geplantesDatum}\", \"zeit\": \"${updated.beginnZeit}\", \"platz\": \"${updated.austragungsplatzId}\"}}" - } + AuditLogTable.insert { + it[entityType] = "BEWERB" + it[entityId] = id.toJavaUuid() + it[action] = "UPDATE_ZEITPLAN" + it[timestamp] = kotlin.time.Clock.System.now() + it[changesJson] = "{\"old\": {\"datum\": \"${current.geplantesDatum}\", \"zeit\": \"${current.beginnZeit}\", \"platz\": \"${current.austragungsplatzId}\"}, \"new\": {\"datum\": \"${updated.geplantesDatum}\", \"zeit\": \"${updated.beginnZeit}\", \"platz\": \"${updated.austragungsplatzId}\"}}" + } } return repo.update(updated) } + fun getAuditLog(bewerbId: Uuid): List = tenantTransaction { + AuditLogTable.selectAll() + .where { AuditLogTable.entityId eq bewerbId.toJavaUuid() } + .orderBy(AuditLogTable.timestamp, SortOrder.DESC) + .map { + AuditLogEntry( + id = Uuid.parse(it[AuditLogTable.id].toString()), + entityType = it[AuditLogTable.entityType], + entityId = Uuid.parse(it[AuditLogTable.entityId].toString()), + action = it[AuditLogTable.action], + userId = it[AuditLogTable.userId]?.let { uid -> Uuid.parse(uid.toString()) }, + timestamp = it[AuditLogTable.timestamp], + changesJson = it[AuditLogTable.changesJson] + ) + } + } + suspend fun delete(id: Uuid) { val current = get(id) if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED – Bewerbe können nicht gelöscht werden") diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt index cd90e86f..0c682a5e 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt @@ -7,6 +7,8 @@ import at.mocode.core.domain.model.BeginnZeitTypE import at.mocode.entries.domain.model.AbteilungsWarnung import at.mocode.entries.domain.model.AbteilungsWarnungCodeE import at.mocode.entries.domain.model.RichterEinsatz +import at.mocode.zns.parser.ZnsBewerb +import at.mocode.zns.parser.ZnsBewerbParser import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalTime import org.springframework.http.HttpStatus @@ -161,6 +163,16 @@ data class AbteilungsWarnungDto( val oetoParagraph: String? ) +data class AuditLogEntryDto( + val id: String, + val entityType: String, + val entityId: String, + val action: String, + val userId: String?, + val timestamp: String, + val changesJson: String? +) + private fun RichterEinsatzDto.toDomain(): RichterEinsatz = RichterEinsatz( funktionaerId = Uuid.parse(this.funktionaerId), @@ -266,4 +278,39 @@ class BewerbeController( val warnungen = service.validateBewerb(uuid) return domainToDto(b, warnungen) } + + @GetMapping("/bewerbe/{id}/audit-log") + suspend fun getAuditLog(@PathVariable id: String): List { + return service.getAuditLog(Uuid.parse(id)).map { + AuditLogEntryDto( + id = it.id.toString(), + entityType = it.entityType, + entityId = it.entityId.toString(), + action = it.action, + userId = it.userId?.toString(), + timestamp = it.timestamp.toString(), + changesJson = it.changesJson + ) + } + } + + @GetMapping("/turniere/{turnierId}/export/zns/b-satz") + suspend fun exportZnsBSatz(@PathVariable turnierId: String): String { + val turnierUuid = Uuid.parse(turnierId) + val bewerbe = service.list(turnierUuid, null, null) + val sb = StringBuilder() + sb.append("BBEWERBE\r\n") + bewerbe.forEach { b -> + val znsBewerb = ZnsBewerb( + bewerbNummer = b.znsNummer ?: 0, + abteilung = b.znsAbteilung ?: 0, + name = b.bezeichnung, + klasse = b.klasse, + kategorie = "", // Wird aktuell nicht in Bewerb gespeichert + datum = b.geplantesDatum + ) + sb.append(ZnsBewerbParser.build(znsBewerb)).append("\r\n") + } + return sb.toString() + } } diff --git a/core/core-utils/src/commonMain/kotlin/at/mocode/core/utils/parser/FixedWidthParser.kt b/core/core-utils/src/commonMain/kotlin/at/mocode/core/utils/parser/FixedWidthParser.kt index 38262d89..693d93c4 100644 --- a/core/core-utils/src/commonMain/kotlin/at/mocode/core/utils/parser/FixedWidthParser.kt +++ b/core/core-utils/src/commonMain/kotlin/at/mocode/core/utils/parser/FixedWidthParser.kt @@ -1,6 +1,7 @@ package at.mocode.core.utils.parser import kotlinx.datetime.LocalDate +import kotlinx.datetime.number /** * A simple utility to parse fixed-width strings based on 1-based start positions and lengths. @@ -61,3 +62,34 @@ class FixedWidthLineReader(private val line: String) { } } } + +/** + * Utility to build fixed-width lines based on 1-based start positions and lengths. + */ +class FixedWidthLineBuilder(length: Int) { + private val buffer = CharArray(length) { ' ' } + + fun setString(start1Based: Int, length: Int, value: String?) { + if (value == null) return + val start0Based = start1Based - 1 + val v = value.take(length) + v.forEachIndexed { index, c -> + if (start0Based + index < buffer.size) { + buffer[start0Based + index] = c + } + } + } + + fun setInt(start1Based: Int, length: Int, value: Int?) { + if (value == null) return + setString(start1Based, length, value.toString().padStart(length, '0')) + } + + fun setLocalDate(start1Based: Int, value: LocalDate?) { + if (value == null) return + val str = "${value.year}${value.month.number.toString().padStart(2, '0')}${value.day.toString().padStart(2, '0')}" + setString(start1Based, 8, str) + } + + override fun toString(): String = buffer.concatToString() +} diff --git a/core/core-utils/src/jvmMain/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt b/core/core-utils/src/jvmMain/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt index cd64617b..1ec00157 100644 --- a/core/core-utils/src/jvmMain/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt +++ b/core/core-utils/src/jvmMain/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt @@ -3,7 +3,7 @@ package at.mocode.core.utils.database import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.exposed.v1.core.Transaction -import org.jetbrains.exposed.v1.jdbc.transactions.experimental.newSuspendedTransaction +import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction /** * Utility for database operations using Exposed. @@ -15,7 +15,7 @@ object DatabaseFactory { */ suspend fun dbQuery(block: suspend Transaction.() -> T): T = withContext(Dispatchers.IO) { - newSuspendedTransaction { + suspendTransaction { block() } } diff --git a/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsBewerbParser.kt b/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsBewerbParser.kt index e73a08eb..6ad82423 100644 --- a/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsBewerbParser.kt +++ b/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsBewerbParser.kt @@ -1,5 +1,6 @@ package at.mocode.zns.parser +import at.mocode.core.utils.parser.FixedWidthLineBuilder import at.mocode.core.utils.parser.FixedWidthLineReader import kotlinx.datetime.LocalDate @@ -70,4 +71,36 @@ object ZnsBewerbParser { datum = datum ) } + + /** + * Erzeugt eine B-Satz Zeile für die n2-XXXXX.dat Datei. + * Verwendet eine Standardlänge von 80 Zeichen. + */ + fun build(bewerb: ZnsBewerb): String { + val builder = FixedWidthLineBuilder(80) + // Stelle 1: ID (Blank) - Standardmäßig Blank durch Buffer-Initialisierung + + // Stelle 2-3: Bewerbnummer (2-stellig) + builder.setInt(2, 2, bewerb.bewerbNummer % 100) + + // Stelle 4: Abteilung + builder.setInt(4, 1, bewerb.abteilung) + + // Stelle 5-39: Bewerbname + builder.setString(5, 35, bewerb.name) + + // Stelle 40-43: Klasse + builder.setString(40, 4, bewerb.klasse) + + // Stelle 44-51: Kategorie + builder.setString(44, 8, bewerb.kategorie) + + // Stelle 53-60: Datum (JJJJMMTT) - Achtung: FixedWidthLineReader nutzte 53,8 (1-basiert) + builder.setLocalDate(53, bewerb.datum) + + // Stelle 61-63: Bewerbnummer (3-stellig) + builder.setInt(61, 3, bewerb.bewerbNummer) + + return builder.toString() + } } diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt index 9974b148..278d6ac2 100644 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/BewerbRepository.kt @@ -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> suspend fun getById(id: Long): Result suspend fun create(model: Bewerb): Result suspend fun update(id: Long, model: Bewerb): Result suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result + suspend fun getAuditLog(bewerbId: Long): Result> + suspend fun exportZnsBSatz(turnierId: Long): Result suspend fun delete(id: Long): Result suspend fun importBewerbe(turnierId: Long, bewerbe: List): Result } diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt index 58f0bb2f..23cb936e 100644 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt @@ -44,6 +44,11 @@ data class BewerbState( val currentStartliste: List = emptyList(), val discoveredNodes: List = emptyList(), val isScanning: Boolean = false, + // Zeitplan-Audit + val auditLog: List = 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}") } + } } } diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt index c656fa91..698aef97 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultBewerbRepository.kt @@ -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): Result = runCatching { - val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") { - setBody(bewerbe) + override suspend fun importBewerbe(turnierId: Long, bewerbe: List): Result = + 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 = 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 = 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 = + 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().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> = runCatching { + val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/audit-log") when { - response.status.isSuccess() -> response.body().toDomain() + response.status.isSuccess() -> response.body>() 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 = runCatching { + val response = client.get("${ApiRoutes.API_PREFIX}/turniere/$turnierId/export/zns/b-satz") + when { + response.status.isSuccess() -> response.body() + 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 = runCatching { val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id") when { diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt index 4d9bc10e..155aaccc 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierZeitplanTab.kt @@ -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 = { diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt index b5172201..59f4b0c0 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt @@ -123,6 +123,8 @@ fun PreviewTurnierBewerbeTab() { override suspend fun update(id: Long, model: Bewerb): Result = Result.failure(NotImplementedError()) override suspend fun delete(id: Long): Result = Result.success(Unit) override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result = Result.failure(NotImplementedError()) + override suspend fun getAuditLog(bewerbId: Long): Result> = Result.success(emptyList()) + override suspend fun exportZnsBSatz(turnierId: Long): Result = Result.success("BBEWERBE\r\n B0100Bewerb 1 A01 20260411001\r\n") override suspend fun importBewerbe(turnierId: Long, bewerbe: List): Result = Result.success(Unit) } val mockStartlistenRepo = object : StartlistenRepository {