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:
@@ -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)
|
||||||
|
|||||||
+37
-9
@@ -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")
|
||||||
|
|||||||
+47
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -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}") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.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 {
|
||||||
|
|||||||
+155
-30
@@ -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 = {
|
||||||
|
|||||||
+2
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user