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:
2026-04-11 21:23:33 +02:00
parent d224e2c521
commit 92aecf9abf
11 changed files with 402 additions and 62 deletions
@@ -19,6 +19,7 @@ dependencies {
implementation(projects.backend.services.billing.billingService) implementation(projects.backend.services.billing.billingService)
implementation(projects.core.coreUtils) implementation(projects.core.coreUtils)
implementation(projects.core.coreDomain) implementation(projects.core.coreDomain)
implementation(projects.core.znsParser)
implementation(projects.backend.infrastructure.monitoring.monitoringClient) implementation(projects.backend.infrastructure.monitoring.monitoringClient)
implementation(projects.backend.infrastructure.security) implementation(projects.backend.infrastructure.security)
@@ -26,7 +27,7 @@ dependencies {
implementation(libs.bundles.spring.boot.secure.service) implementation(libs.bundles.spring.boot.secure.service)
// Common service extras // Common service extras
implementation(libs.spring.boot.starter.validation) 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("org.springframework.boot:spring-boot-starter-web")
implementation(libs.spring.boot.starter.json) implementation(libs.spring.boot.starter.json)
implementation(libs.postgresql.driver) implementation(libs.postgresql.driver)
@@ -54,6 +55,7 @@ dependencies {
// Flyway runtime (provided by BOM, ensure availability in this module) // Flyway runtime (provided by BOM, ensure availability in this module)
implementation(libs.flyway.core) implementation(libs.flyway.core)
implementation(libs.flyway.postgresql) implementation(libs.flyway.postgresql)
implementation(project(":core:zns-parser"))
testImplementation(projects.platform.platformTesting) testImplementation(projects.platform.platformTesting)
testImplementation(libs.bundles.testing.jvm) testImplementation(libs.bundles.testing.jvm)
@@ -8,13 +8,24 @@ import at.mocode.entries.domain.service.CompetitionWarningService
import at.mocode.entries.service.errors.LockedException import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.persistence.AuditLogTable import at.mocode.entries.service.persistence.AuditLogTable
import at.mocode.entries.service.persistence.TurnierTable import at.mocode.entries.service.persistence.TurnierTable
import org.jetbrains.exposed.v1.jdbc.insert
import at.mocode.entries.service.tenant.tenantTransaction 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.core.eq
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid 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( class BewerbService(
private val repo: BewerbRepository, private val repo: BewerbRepository,
private val nennungen: NennungRepository, private val nennungen: NennungRepository,
@@ -158,7 +169,7 @@ class BewerbService(
suspend fun updateZeitplan(id: Uuid, req: UpdateZeitplanRequest): Bewerb { suspend fun updateZeitplan(id: Uuid, req: UpdateZeitplanRequest): Bewerb {
val current = get(id) 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( val updated = current.copy(
geplantesDatum = req.geplantesDatum, geplantesDatum = req.geplantesDatum,
beginnZeit = req.beginnZeit, beginnZeit = req.beginnZeit,
@@ -167,18 +178,35 @@ class BewerbService(
tenantTransaction { tenantTransaction {
// Audit-Log schreiben // Audit-Log schreiben
AuditLogTable.insert { AuditLogTable.insert {
it[entityType] = "BEWERB" it[entityType] = "BEWERB"
it[entityId] = id.toJavaUuid() it[entityId] = id.toJavaUuid()
it[action] = "UPDATE_ZEITPLAN" it[action] = "UPDATE_ZEITPLAN"
it[timestamp] = kotlin.time.Clock.System.now() 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}\"}}" 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) return repo.update(updated)
} }
fun getAuditLog(bewerbId: Uuid): List<AuditLogEntry> = 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) { suspend fun delete(id: Uuid) {
val current = get(id) val current = get(id)
if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht gelöscht werden") if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht gelöscht werden")
@@ -7,6 +7,8 @@ import at.mocode.core.domain.model.BeginnZeitTypE
import at.mocode.entries.domain.model.AbteilungsWarnung import at.mocode.entries.domain.model.AbteilungsWarnung
import at.mocode.entries.domain.model.AbteilungsWarnungCodeE import at.mocode.entries.domain.model.AbteilungsWarnungCodeE
import at.mocode.entries.domain.model.RichterEinsatz 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.LocalDate
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
@@ -161,6 +163,16 @@ data class AbteilungsWarnungDto(
val oetoParagraph: String? 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 = private fun RichterEinsatzDto.toDomain(): RichterEinsatz =
RichterEinsatz( RichterEinsatz(
funktionaerId = Uuid.parse(this.funktionaerId), funktionaerId = Uuid.parse(this.funktionaerId),
@@ -266,4 +278,39 @@ class BewerbeController(
val warnungen = service.validateBewerb(uuid) val warnungen = service.validateBewerb(uuid)
return domainToDto(b, warnungen) return domainToDto(b, warnungen)
} }
@GetMapping("/bewerbe/{id}/audit-log")
suspend fun getAuditLog(@PathVariable id: String): List<AuditLogEntryDto> {
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()
}
} }
@@ -1,6 +1,7 @@
package at.mocode.core.utils.parser package at.mocode.core.utils.parser
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.number
/** /**
* A simple utility to parse fixed-width strings based on 1-based start positions and lengths. * 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()
}
@@ -3,7 +3,7 @@ package at.mocode.core.utils.database
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.Transaction 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. * Utility for database operations using Exposed.
@@ -15,7 +15,7 @@ object DatabaseFactory {
*/ */
suspend fun <T> dbQuery(block: suspend Transaction.() -> T): T = suspend fun <T> dbQuery(block: suspend Transaction.() -> T): T =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
newSuspendedTransaction { suspendTransaction {
block() block()
} }
} }
@@ -1,5 +1,6 @@
package at.mocode.zns.parser package at.mocode.zns.parser
import at.mocode.core.utils.parser.FixedWidthLineBuilder
import at.mocode.core.utils.parser.FixedWidthLineReader import at.mocode.core.utils.parser.FixedWidthLineReader
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -70,4 +71,36 @@ object ZnsBewerbParser {
datum = datum 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()
}
} }
@@ -25,12 +25,24 @@ data class AbteilungsWarnung(
val oetoParagraph: String? 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 { interface BewerbRepository {
suspend fun list(turnierId: Long): Result<List<Bewerb>> suspend fun list(turnierId: Long): Result<List<Bewerb>>
suspend fun getById(id: Long): Result<Bewerb> suspend fun getById(id: Long): Result<Bewerb>
suspend fun create(model: Bewerb): Result<Bewerb> suspend fun create(model: Bewerb): Result<Bewerb>
suspend fun update(id: Long, 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 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 delete(id: Long): Result<Unit>
suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit> suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit>
} }
@@ -44,6 +44,11 @@ data class BewerbState(
val currentStartliste: List<StartlistenZeile> = emptyList(), val currentStartliste: List<StartlistenZeile> = emptyList(),
val discoveredNodes: List<DiscoveredService> = emptyList(), val discoveredNodes: List<DiscoveredService> = emptyList(),
val isScanning: Boolean = false, 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) // Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
val dialogState: BewerbAnlegenState = BewerbAnlegenState(), val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
) )
@@ -71,6 +76,9 @@ sealed interface BewerbIntent {
data object StopNetworkScan : BewerbIntent data object StopNetworkScan : BewerbIntent
data object RefreshDiscoveredNodes : BewerbIntent data object RefreshDiscoveredNodes : BewerbIntent
data class UpdateZeitplan(val id: Long, val beginnZeit: String?) : 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.StopNetworkScan -> stopScan()
is BewerbIntent.RefreshDiscoveredNodes -> refreshNodes() is BewerbIntent.RefreshDiscoveredNodes -> refreshNodes()
is BewerbIntent.UpdateZeitplan -> updateZeitplan(intent.id, intent.beginnZeit) 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}") }
}
} }
} }
@@ -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.toDomain
import at.mocode.turnier.feature.data.mapper.toDto import at.mocode.turnier.feature.data.mapper.toDto
import at.mocode.turnier.feature.data.remote.dto.BewerbDto 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.Bewerb
import at.mocode.turnier.feature.domain.BewerbRepository import at.mocode.turnier.feature.domain.BewerbRepository
import io.ktor.client.* 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 { override suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit> =
val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") { runCatching {
setBody(bewerbe) 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 { override suspend fun getById(id: Long): Result<Bewerb> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id") 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 { override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> =
val response = client.patch("${ApiRoutes.API_PREFIX}/bewerbe/$id/zeitplan") { runCatching {
contentType(ContentType.Application.Json) val response = client.patch("${ApiRoutes.API_PREFIX}/bewerbe/$id/zeitplan") {
setBody(mapOf( contentType(ContentType.Application.Json)
"geplantesDatum" to datum, setBody(
"beginnZeit" to beginn, mapOf(
"austragungsplatzId" to platzId "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 { when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain() response.status.isSuccess() -> response.body<List<AuditLogEntry>>()
response.status == HttpStatusCode.NotFound -> throw NotFound() response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError() response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value) 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 { override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id") val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id")
when { when {
@@ -2,8 +2,11 @@ package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.* 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -67,46 +70,90 @@ fun ZeitplanTabContent(
} }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var showAuditLog by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) { Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) {
Column(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
// Header / Toolbar Column(modifier = Modifier.weight(1f)) {
ZeitplanToolbar() // Header / Toolbar
ZeitplanToolbar(viewModel = viewModel, onShowHistory = { showAuditLog = !showAuditLog })
Row(modifier = Modifier.weight(1f)) { Row(modifier = Modifier.weight(1f)) {
// Zeit-Achse (feststehend) // Zeit-Achse (feststehend)
ZeitAchse() ZeitAchse()
// Content (scrollbar) // Content (scrollbar)
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.verticalScroll(scrollState) .verticalScroll(scrollState)
) { ) {
// Hintergrund-Gitter // Hintergrund-Gitter
ZeitplanGitter() ZeitplanGitter()
// Bewerbe / Blöcke // Bewerbe / Blöcke
items.forEach { item -> items.forEach { item ->
DraggableBewerbBox( DraggableBewerbBox(
item = item, item = item,
onPositionChange = { newMinutes -> onPositionChange = { newMinutes ->
val h = newMinutes / 60 val h = newMinutes / 60
val m = newMinutes % 60 val m = newMinutes % 60
val timeStr = "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}" val timeStr = "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}"
viewModel.send(BewerbIntent.UpdateZeitplan(item.id, timeStr)) 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 @Composable
private fun ZeitplanToolbar() { private fun ZeitplanToolbar(
viewModel: BewerbViewModel,
onShowHistory: () -> Unit = {}
) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(12.dp), modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -115,6 +162,10 @@ private fun ZeitplanToolbar() {
Text("Zeitplan-Optimierung", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = ZeitplanBlue) Text("Zeitplan-Optimierung", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = ZeitplanBlue)
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
TextButton(onClick = onShowHistory) {
Text("Historie anzeigen", color = ZeitplanBlue, fontSize = 13.sp)
}
// Platz-Filter (Mock) // Platz-Filter (Mock)
Text("Platz:", fontSize = 13.sp) Text("Platz:", fontSize = 13.sp)
AssistChip(onClick = {}, label = { Text("Hauptplatz") }) AssistChip(onClick = {}, label = { Text("Hauptplatz") })
@@ -123,10 +174,82 @@ private fun ZeitplanToolbar() {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Button( Button(
onClick = { /* Speichern */ }, onClick = { viewModel.send(BewerbIntent.ExportZnsBSatz) },
colors = ButtonDefaults.buttonColors(containerColor = ZeitplanBlue) 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 @Composable
private fun DraggableBewerbBox( private fun DraggableBewerbBox(
item: ZeitplanItemUi, item: ZeitplanItemUi,
onPositionChange: (Int) -> Unit onPositionChange: (Int) -> Unit,
onClick: () -> Unit = {}
) { ) {
// Berechnung der Position basierend auf den Startminuten seit START_HOUR // Berechnung der Position basierend auf den Startminuten seit START_HOUR
val relativeMinutes = item.startMinutes - (START_HOUR * 60) val relativeMinutes = item.startMinutes - (START_HOUR * 60)
@@ -197,6 +321,7 @@ private fun DraggableBewerbBox(
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.background(item.color.copy(alpha = 0.15f)) .background(item.color.copy(alpha = 0.15f))
.border(1.dp, item.color, RoundedCornerShape(6.dp)) .border(1.dp, item.color, RoundedCornerShape(6.dp))
.clickable(onClick = onClick)
.pointerInput(Unit) { .pointerInput(Unit) {
detectDragGestures( detectDragGestures(
onDragEnd = { onDragEnd = {
@@ -123,6 +123,8 @@ fun PreviewTurnierBewerbeTab() {
override suspend fun update(id: Long, model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError()) 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 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 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) override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit)
} }
val mockStartlistenRepo = object : StartlistenRepository { val mockStartlistenRepo = object : StartlistenRepository {