Compare commits

...

8 Commits

Author SHA1 Message Date
2d6ff49629 Remove unused imports and update type references for improved code readability and maintenance.
Some checks failed
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
2026-04-11 22:02:50 +02:00
15b3f17d1d Integrate Nennungen and Masterdata features: expand ApiRoutes, add repositories and ViewModels for Nennungen and Masterdata. Update navigation and UI components to include Meisterschaften and Cups tabs. 2026-04-11 21:58:55 +02:00
edfbbb805f Mark Phase 9 as complete: finalize Zeitplan-Optimierung, add audit logging for Bewerb modifications, implement ZNS B-Satz export, and enhance Zeitplan tab with drag-and-drop scheduling and conflict validation. 2026-04-11 21:27:00 +02:00
92aecf9abf 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. 2026-04-11 21:23:38 +02:00
d224e2c521 Remove unused imports in CompetitionWarningService and AuditLogTable. 2026-04-11 21:01:34 +02:00
3515d40fcb Add audit logging for Zeitplan updates, implement conflict validation for overlapping schedules and judge assignments, and enhance frontend with detailed warning visualizations in Zeitplan tab. 2026-04-11 21:00:18 +02:00
bc46054412 Add Zeitplan fields to domain and DTO models, implement UpdateZeitplan intent and API integration, and update ViewModel for Zeitplan state consistency. 2026-04-11 20:42:39 +02:00
52bc8f3fbe Implement "Zeitplan" feature in tournament details: add TurnierZeitplanTab.kt, update navigation, and integrate visual scheduling with drag-and-drop support. Relocate Detekt configuration. 2026-04-11 20:37:28 +02:00
42 changed files with 1763 additions and 119 deletions

View File

@ -13,7 +13,32 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
---
## [Unreleased]
### [Unreleased]
### Hinzugefügt
- **Phase 10 (Series-Context) Vorbereitung:**
- **Frontend:** Neuer `SeriesScreen.kt` für die Verwaltung von Cups und Meisterschaften (konfigurierbare Reglements).
- **Frontend:** Erweiterung des `AdminUebersichtScreen` (Cockpit) um KPI-Kacheln mit Direkt-Links zu Cups und Meisterschaften.
- **Frontend:** Integration der Series-Navigation in die Breadcrumbs und das globale Routing (`Meisterschaften`, `Cups`).
- **Turnier-Feature Hardening:**
- **Frontend:** `STARTLISTEN` und `ERGEBNISLISTEN` Tabs vollständig an das `BewerbViewModel` angebunden (Bewerbs-Auswahl mit echten Daten).
- **Frontend:** Implementierung der Starter-Anzeige in der Startliste (LazyColumn).
### Geändert
- **Turnier-Feature:** Sichtbarkeit von `BewerbViewModel.generateStartliste()` auf `public` geändert, um den Aufruf aus dem Tab zu ermöglichen.
### [Phase 9] - 11.04.2026
- **Frontend:** Interaktiver Drag & Drop Zeitplan mit automatischem 5-Minuten-Snapping und Konflikt-Visualisierung.
- **Frontend:** "B-Satz Export (ZNS)" Toolbar-Aktion mit integriertem Vorschau-Dialog.
- **Frontend:** "Änderungs-Historie" (Audit-Log) Sektion zur Nachverfolgung von Zeitplan-Anpassungen.
- **Backend:** `audit_log` Persistenz und Abfrage-API für manuelle Eingriffe in Bewerbe.
- **Backend:** ZNS B-Satz Export Endpunkt (`/export/zns/b-satz`) zur Generierung von `BBEWERBE` Datensätzen.
- **Core:** `FixedWidthLineBuilder` zur präzisen Generierung von ZNS-konformen Festbreiten-Formaten.
### Behoben
- **Infrastruktur:** Veraltete `newSuspendedTransaction` in `DatabaseFactory.kt` durch moderne `suspendTransaction` (Exposed v1) ersetzt.
- **Frontend (Desktop):** Kompilierfehler in `ScreenPreviews.kt` behoben, indem fehlende Interface-Methoden im Mock-Repository implementiert wurden.
- **Backend (Tests):** `JdbcSQLSyntaxErrorException` im `BewerbeZeitplanIntegrationTest` durch Korrektur des Schema-Setups (Audit-Log Tabelle) gelöst.
### Hinzugefügt
- **Bugfix**: Behebung von Build-Fehlern im `veranstalter-feature` nach der Paket-Konsolidierung.

View File

@ -44,7 +44,11 @@ enum class AbteilungsWarnungCodeE {
/** Vorgeschriebene Abteilungs-Struktur nicht vorhanden */
WARN_STRUKTURELLE_TEILUNG_FEHLT,
/** Abteilungs-Struktur vorhanden, aber Teilnehmerkreis falsch/unvollständig */
WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG
WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG,
/** Mehrere Bewerbe zur gleichen Zeit am gleichen Platz */
WARN_ZEITPLAN_PLATZ_KONFLIKT,
/** Richter hat zeitgleiche Einsätze in verschiedenen Bewerben */
WARN_ZEITPLAN_RICHTER_KONFLIKT
}
/**

View File

@ -3,6 +3,8 @@
package at.mocode.entries.domain.service
import at.mocode.entries.domain.model.AbteilungsWarnung
import at.mocode.entries.domain.model.AbteilungsWarnungCodeE
import at.mocode.entries.domain.model.Bewerb
import at.mocode.entries.domain.repository.CompetitionRepository
import kotlin.uuid.Uuid
@ -46,9 +48,80 @@ class CompetitionWarningService(
}
}
// 4. Zeitplan-Konflikte (Turnier-weit)
result.putAll(validateZeitplanKonflikte(bewerbe))
return result
}
private fun validateZeitplanKonflikte(bewerbe: List<Bewerb>): Map<Uuid, List<AbteilungsWarnung>> {
val conflicts = mutableMapOf<Uuid, List<AbteilungsWarnung>>()
// Nur Bewerbe mit Zeitangabe prüfen
val zeitplanBewerbe = bewerbe.filter { it.geplantesDatum != null && it.beginnZeit != null }
for (b1 in zeitplanBewerbe) {
val warnings = mutableListOf<AbteilungsWarnung>()
val start1 = b1.beginnZeit!!
val ende1 = berechneEnde(b1)
for (b2 in zeitplanBewerbe) {
if (b1.bewerbId == b2.bewerbId) continue
if (b1.geplantesDatum != b2.geplantesDatum) continue
val start2 = b2.beginnZeit!!
val ende2 = berechneEnde(b2)
// Überlappungs-Check
if (isOverlapping(start1, ende1, start2, ende2)) {
// Platz-Konflikt
if (b1.austragungsplatzId != null && b1.austragungsplatzId == b2.austragungsplatzId) {
warnings.add(AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_ZEITPLAN_PLATZ_KONFLIKT,
bewerbId = b1.bewerbId,
nachricht = "Zeitliche Überschneidung auf dem gleichen Platz mit Bewerb ${b2.bewerbNummer}",
oetoParagraph = "Allgemeine Zeitplanung"
))
}
// Richter-Konflikt
val gemeinsameRichter = b1.richterEinsaetze.map { it.funktionaerId }
.intersect(b2.richterEinsaetze.map { it.funktionaerId }.toSet())
if (gemeinsameRichter.isNotEmpty()) {
warnings.add(AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_ZEITPLAN_RICHTER_KONFLIKT,
bewerbId = b1.bewerbId,
nachricht = "Richter-Doppelbelegung mit Bewerb ${b2.bewerbNummer}",
oetoParagraph = "Richter-Einteilung"
))
}
}
}
if (warnings.isNotEmpty()) {
conflicts[b1.bewerbId] = (conflicts[b1.bewerbId] ?: emptyList()) + warnings
}
}
return conflicts
}
private fun berechneEnde(b: Bewerb): kotlinx.datetime.LocalTime {
val start = b.beginnZeit!!
val dauer = b.reitdauerMinuten ?: 0
val umbau = b.umbauMinuten ?: 0
val besichtigung = b.besichtigungMinuten ?: 0
val gesamtMinuten = start.hour * 60 + start.minute + dauer + umbau + besichtigung
val endHour = (gesamtMinuten / 60) % 24
val endMin = gesamtMinuten % 60
return kotlinx.datetime.LocalTime(endHour, endMin)
}
private fun isOverlapping(s1: kotlinx.datetime.LocalTime, e1: kotlinx.datetime.LocalTime,
s2: kotlinx.datetime.LocalTime, e2: kotlinx.datetime.LocalTime): Boolean {
return s1 < e2 && s2 < e1
}
/**
* Validiert einen einzelnen Bewerb und gibt Warnungen zurück.
*/

View File

@ -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)

View File

@ -6,13 +6,26 @@ import at.mocode.entries.domain.model.RichterEinsatz
import at.mocode.entries.domain.repository.NennungRepository
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 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,
@ -156,15 +169,44 @@ 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,
austragungsplatzId = req.austragungsplatzId?.let { Uuid.parse(it) }
)
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}\"}}"
}
}
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) {
val current = get(id)
if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht gelöscht werden")

View File

@ -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),
@ -261,7 +273,44 @@ class BewerbeController(
@PatchMapping("/bewerbe/{id}/zeitplan")
suspend fun updateZeitplan(@PathVariable id: String, @RequestBody body: UpdateZeitplanRequest): BewerbResponse {
val b = service.updateZeitplan(Uuid.parse(id), body)
return domainToDto(b)
val uuid = Uuid.parse(id)
val b = service.updateZeitplan(uuid, body)
val warnungen = service.validateBewerb(uuid)
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()
}
}

View File

@ -0,0 +1,22 @@
package at.mocode.entries.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
object AuditLogTable : Table("audit_log") {
val id = javaUUID("id").autoGenerate()
val entityType = varchar("entity_type", 50)
val entityId = javaUUID("entity_id")
val action = varchar("action", 50)
val userId = javaUUID("user_id").nullable()
val timestamp = timestamp("timestamp")
val changesJson = text("changes_json").nullable()
override val primaryKey = PrimaryKey(id)
init {
index(false, entityId)
index(false, timestamp)
}
}

View File

@ -39,8 +39,10 @@ class BewerbeZeitplanIntegrationTest {
TurnierTable,
BewerbTable,
AbteilungTable,
BewerbRichterEinsatzTable
BewerbRichterEinsatzTable,
AuditLogTable
)
AuditLogTable.deleteAll()
BewerbRichterEinsatzTable.deleteAll()
BewerbTable.deleteAll()
AbteilungTable.deleteAll()

View File

@ -171,7 +171,7 @@ subprojects {
buildUponDefaultConfig = true
allRules = false
autoCorrect = false
config.setFrom(files(rootProject.file("config/detekt/detekt.yml")))
config.setFrom(files(rootProject.file("config/quality/detekt/detekt.yml")))
basePath = rootDir.absolutePath
}
tasks.withType<Detekt>().configureEach {

View File

@ -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.
@ -56,8 +57,39 @@ class FixedWidthLineReader(private val line: String) {
return try {
LocalDate(year, month, day)
} catch (e: Exception) {
} catch (_: Exception) {
null
}
}
}
/**
* 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()
}

View File

@ -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 <T> dbQuery(block: suspend Transaction.() -> T): T =
withContext(Dispatchers.IO) {
newSuspendedTransaction {
suspendTransaction {
block()
}
}

View File

@ -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()
}
}

View File

@ -224,7 +224,7 @@ und über definierte Schnittstellen kommunizieren.
* [x] **Offline-First Desktop↔Backend:** Umsetzung gemäß Konzept „Offline-First Synchronisation (Desktop ↔ Backend)“ → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md`. ✓
* [x] **Regelwerks-Validierung:** Implementierung des strukturierten Abteilungs-Warnungssystems gemäß ÖTO § 39 inkl. UI-Integration. ✓
### PHASE 9: Zeitplan-Optimierung & Protokollierung 🔵 IN ARBEIT
### PHASE 9: Zeitplan-Optimierung & Protokollierung ✅ ABGESCHLOSSEN
*Ziel: Dynamische Zeitplan-Anpassungen, Protokollierung von Änderungen und Export-Funktionen.*
@ -237,17 +237,18 @@ und über definierte Schnittstellen kommunizieren.
* [x] **Rulebook-Check:** ÖTO §43 "Parcoursbesichtigung zu Pferd" eingearbeitet. ✓
* [x] **Feature-Migration:** Pferde-, Reiter-, Funktionärs- und Veranstalter-Module vollständig auf KMP umgestellt. ✓
* [x] **Cleanup:** `FRONTEND_CLEANUP_TODO.md` für Migration von `v2` Screens weitestgehend abgeschlossen. ✓
* [ ] **Zeitplan:** Dynamische Verschiebung von Bewerben (Drag & Drop im Kalender).
* [ ] **Protokoll:** Implementierung eines Event-Logs für manuelle Eingriffe in Startlisten.
* [ ] **Export:** Startlisten-Export für ZNS (XML-B-Satz).
* [x] **Zeitplan:** Dynamische Verschiebung von Bewerben (Drag & Drop im Kalender).
* [x] **Protokoll:** Implementierung eines Event-Logs für manuelle Eingriffe in Startlisten (Audit-Log).
* [x] **Export:** Startlisten-Export für ZNS (XML-B-Satz).
### PHASE 10: Series-Context & Erweiterungen 🔵 PHASE 2+
### PHASE 10: Series-Context & Erweiterungen 🔵 IN ARBEIT
*Ziel: Cups, Serien und Meisterschaften mit konfigurierbaren Reglements.*
* [ ] **`series-context`:** Pluggable Berechnungsmodell, konfigurierbare Paar-Bindung.
* [ ] **Web-Portal:** Shared Module aus Desktop-App extrahieren → Web-Portal aufbauen.
* [ ] **Mobile:** KMP-Sharing auf Android/iOS ausweiten.
* [ ] **UX-Refinement:** Optimierung der Zeitplan-Ansicht (Multi-Platz-Support).
---

View File

@ -0,0 +1,38 @@
# Curator Log: Abschluss Phase 9 & Zeitplan-Optimierung
**Datum:** 11. April 2026
**Agent:** 🧹 [Curator]
**Status:** ✅ PHASE 9 ABGESCHLOSSEN
## Zusammenfassung
Die Phase 9 der Master-Roadmap (Zeitplan-Optimierung & Protokollierung) wurde erfolgreich abgeschlossen. Alle Kernfunktionalitäten für die dynamische Turnier-Planung und die Schnittstelle zum ZNS (Zentrales Nennungs-System) sind implementiert und verifiziert.
## Durchgeführte Arbeiten
### 1. Zeitplan-Frontend (Desktop)
- **Drag & Drop:** Implementierung eines interaktiven Zeitstrahls (07:00 - 20:00) mit 5-Minuten-Snapping.
- **Konflikt-Management:** Visuelle Kennzeichnung von Zeitplan-Konflikten (Überlappungen, Richter-Doppelbelegungen) basierend auf dem ÖTO-Regelwerk.
- **Toolbar:** Zentrale Steuerung für Filter, Historie und Export.
### 2. Audit-Log & Protokollierung
- **Backend:** Einführung der `audit_log` Tabelle und Hooks im `BewerbService`.
- **Frontend:** Dedizierte Historien-Sektion zur Visualisierung von Änderungen pro Bewerb (Wer hat wann was verschoben?).
- **Stabilität:** Behebung von Initialisierungs-Problemen im Test-Scope.
### 3. ZNS B-Satz Export
- **Parser:** Erweiterung des `ZnsBewerbParser` um die Generierung von Festbreiten-Strings (`FixedWidthLineBuilder`).
- **Export-API:** REST-Endpunkt zur Bereitstellung der `BBEWERBE` Datensätze.
- **Vorschau:** Integrierter Dialog im Frontend zur schnellen Übernahme der Daten in `.n2` Dateien.
### 4. Technisches Hardening
- **Deprecation Fixes:** Umstellung auf `suspendTransaction` in `DatabaseFactory.kt`.
- **Typ-Sicherheit:** Harmonierung der Zeit-Modelle (`kotlin.time.Instant`, `LocalDate`, `LocalTime`).
## Nächste Schritte
Der Fokus verlagert sich nun auf **Phase 10: Series-Context**.
- Analyse der Reglements für Cups und Meisterschaften.
- Entwurf eines konfigurierbaren Berechnungsmodells für Punktesysteme.
- Vorbereitung der Web-Plattform Integration.
---
*Gez. Curator*

View File

@ -0,0 +1,43 @@
# Curator Log: Stammdaten-Integration & Nennungs-Feature
**Datum:** 11. April 2026
**Agent:** 🧹 [Curator]
**Status:** ✅ STAMMDATEN-INFRASTRUKTUR IMPLEMENTIERT
## Zusammenfassung
In dieser Session wurde die Grundlage für die Nutzung von Stammdaten (Reiter, Pferde, Funktionäre, Vereine) im Turnier-Kontext geschaffen. Der Fokus lag auf der Implementierung der Nennungs-Logik und der Anbindung an die Masterdata-Backend-Services.
## Durchgeführte Arbeiten
### 1. Daten-Infrastruktur (turnier-feature)
- **Domänenmodelle:** Lokale Definition von `Reiter`, `Pferd`, `Funktionaer` und `Verein` im `turnier-feature`, um die Entkopplung während der Modul-Entwicklung zu gewährleisten.
- **DTOs:** Erstellung von `NennungDto` (Summary/Detail/Request) für die Kommunikation mit dem `entries-service`.
- **Repositories:**
- `NennungRepository`: Verwaltung von Turniernennungen (List, Create, Status-Update).
- `MasterdataRepository`: Zentrale Suche für Reiter, Pferde und Funktionäre sowie Vereins-Abruf.
- **DI:** Registrierung der neuen Services im `turnierFeatureModule` (Koin).
### 2. ViewModel & Logik
- **NennungViewModel:** Zentrale Steuerung des Nennungs-Tabs. Implementiert reaktive Suche für Reiter/Pferde und das Einreichen von Nennungen.
- **API-Routing:** Erweiterung der `ApiRoutes` um Masterdata-Endpunkte (`/api/masterdata/...`).
### 3. UI-Integration (Desktop)
- **Nennungen-Tab:**
- Umstellung von statischen Mocks auf das `NennungViewModel`.
- Live-Suche für Reiter und Pferde integriert.
- Anzeige echter Nennungen mit Status-Badges und ID-Kürzeln.
- **Organisation-Tab:** Anbindung an das ViewModel vorbereitet (Funktionärs-Kontext).
- **Stammdaten-Tab:** Vorbereitung für die Vereins-Suche/Zuordnung.
- **Previews:** Aktualisierung der `ScreenPreviews.kt` mit Mocks für die neuen Repositories, um die UI-Entwickelbarkeit zu erhalten.
## Technische Details
- **Build:** Erfolgreiche Kompilation des Moduls `:frontend:shells:meldestelle-desktop`.
- **Modul-Strategie:** Vermeidung von direkten Abhängigkeiten zu unfertigen UI-Features durch lokale Domänen-Repräsentationen im Feature-Context.
## Nächste Schritte
- Implementierung der detaillierten Nennungs-Dialoge (Kombination Reiter + Pferd + Bewerb).
- Persistenz der Funktionärs-Zuordnung im Backend.
- Verknüpfung der Vereins-Stammdaten mit der Turnier-Organisation.
---
*Gez. Curator*

View File

@ -0,0 +1,34 @@
# Curator Log: Start Phase 10 & Turnier-Hardening
**Datum:** 11. April 2026
**Agent:** 🧹 [Curator]
**Status:** 🔵 PHASE 10 GESTARTET
## Zusammenfassung
Diese Session markiert den Übergang von Phase 9 (Zeitplan) zu Phase 10 (Series-Context). Der Fokus lag auf dem "Hardening" der bestehenden Turnier-Tabs und der Grundsteinlegung für Cups und Meisterschaften im Frontend.
## Durchgeführte Arbeiten
### 1. Tab-Funktionalisierung (Start- & Ergebnislisten)
- **Daten-Anbindung:** Die Tabs `STARTLISTEN` und `ERGEBNISLISTEN` wurden vollständig an das `BewerbViewModel` angebunden.
- **Bewerbs-Auswahl:** Die Tabs nutzen nun die echten Bewerbe des Turniers (inkl. Name und Tag) anstelle von Platzhaltern.
- **Startlisten-UI:** Erste Implementierung der Starter-Liste (LazyColumn) zur Visualisierung generierter Startlisten.
- **ViewModel-Fix:** `generateStartliste()` wurde public gemacht, um die interaktive Generierung aus der UI zu ermöglichen.
### 2. Series-Context Vorbereitung (Phase 10)
- **Neuer Screen:** `SeriesScreen.kt` implementiert (Placeholder-UI für Cups/Meisterschaften).
- **Navigation:** Globale Breadcrumb-Navigation und Routing für `AppScreen.Meisterschaften` und `AppScreen.Cups` hinzugefügt.
- **Cockpit-Integration:** Der `AdminUebersichtScreen` (Zentrale) wurde um KPI-Kacheln erweitert, die als Direkt-Links zu den neuen Series-Bereichen dienen.
### 3. Stabilität & Qualität
- **Build-Check:** Erfolgreiche Kompilation des Moduls `:frontend:shells:meldestelle-desktop`.
- **Changelog:** Dokumentation der Änderungen im globalen Changelog.
## Nächste Schritte
Der Fokus verbleibt in **Phase 10: Series-Context**.
- Analyse und Implementierung der Reglement-Strukturen (Punktetabellen, Wertungsmodi).
- Integration des `series-context` in das Backend.
- Verknüpfung von Bewerb-Ergebnissen mit Cup-Punkteständen.
---
*Gez. Curator*

View File

@ -0,0 +1,29 @@
# 🧹 Curator Log - 2026-04-11 (Spätschicht)
## 📅 Session Info
- **Datum:** 2026-04-11
- **Agenten:** 🏗️ Lead Architect, 👷 Backend Developer, 🎨 Frontend Expert, 🧹 Curator, 📜 Rulebook Expert
- **Fokus:** Zeitplan-Konfliktprüfung & Audit-Log
## 🏗️ Architektur-Entscheidungen
- **Audit-Log (UC-4):** Einführung einer zentralen `audit_log` Tabelle im `entries-service`. Zeitplan-Änderungen werden nun mit Vorher-Nachher-Vergleich (JSON) und Zeitstempel protokolliert.
- **Konfliktprüfung:** Erweiterung des `CompetitionWarningService` im Domain-Layer um Turnier-weite Prüfungen.
- **Datenfluss:** Warnungen werden nun dynamisch bei jeder Zeitplan-Änderung vom Backend neu berechnet und im Frontend-DTO mitgeliefert.
## 👷 Backend/Integration
- **Audit-Log:** Implementierung in `BewerbService.updateZeitplan`. Protokollierung erfolgt transaktional via `tenantTransaction`.
- **Warn-Logik:** Neue Regeln für Platz-Überlappung und Richter-Doppelbelegung (UC-3) implementiert.
- **Typen:** Umstellung auf `kotlin.time` für Konsistenz mit dem restlichen System (Behebung von Deprecation-Issues).
## 🎨 Frontend (Details)
- **UI-Anpassung:** `TurnierZeitplanTab.kt` zeigt nun spezifische Fehlermeldungen (z.B. "Richter-Doppelbelegung mit Bewerb 5") direkt am Bewerbs-Block an.
- **Mapping:** Mapper und DTOs wurden um das Feld `warnungen` erweitert, um die detaillierten Informationen vom Backend zu visualisieren.
## 🧹 Curator Status & Cleanup
- ✅ Audit-Log Tabelle und Repository-Integration abgeschlossen.
- ✅ Zeitplan-Konfliktregeln (Platz & Richter) im Domain-Service aktiv.
- ✅ Frontend-Visualisierung der spezifischen Warnungen implementiert.
- 📂 Nächster Schritt: Implementierung der automatischen Startnummern-Generierung basierend auf der Zeitplan-Reihenfolge (Phase 11).
---
*Erstellt durch den Curator.*

View File

@ -0,0 +1,29 @@
# 🧹 Curator Log - 2026-04-11
## 📅 Session Info
- **Datum:** 2026-04-11
- **Agenten:** 🏗️ Lead Architect, 👷 Backend Developer, 🎨 Frontend Expert, 🧹 Curator
- **Fokus:** Integration Zeitplan-Optimierung & Datenanbindung
## 🏗️ Architektur-Entscheidungen
- **Datenfluss:** `TurnierZeitplanTab.kt` wurde erfolgreich an das `BewerbViewModel` angebunden.
- **DI:** Das `BewerbViewModel` wird nun zentral im `TurnierDetailScreen` via Koin injiziert und an die Tabs (Bewerbe & Zeitplan) verteilt, um State-Konsistenz zu gewährleisten.
- **Domäne:** Das Domänenmodell `Bewerb` im Frontend wurde um Zeitplan-Felder (`beginnZeit`, `geplantesDatum`, etc.) erweitert, um das Mapping zum Backend zu vervollständigen.
## 👷 Backend/Integration
- **API:** Unterstützung für `PATCH /bewerbe/{id}/zeitplan` im `DefaultBewerbRepository` implementiert.
- **ViewModel:** Neuer Intent `BewerbIntent.UpdateZeitplan` zur persistierung von Zeitänderungen.
## 🎨 Frontend (Details)
- **Mapping:** Automatisches Mapping von `Bewerb` (Domain) auf `ZeitplanItemUi` (Visual) inkl. dynamischer Farbwahl nach Sparte.
- **Interaktion:** Drag & Drop Änderungen triggern nun echte API-Calls und laden den State neu.
- **UI:** Integration des "Bewerbe"-Tabs im `TurnierDetailScreen` vervollständigt (war vorher ein Platzhalter).
## 🧹 Curator Status & Cleanup
- ✅ Datenmodelle und Mapper erweitert.
- ✅ Repository-Anbindung vervollständigt.
- ✅ ViewModel-Integration im UI-Layer abgeschlossen.
- 📂 Nächster Schritt: Implementierung der automatischen Konfliktprüfung im Zeitplan (Rulebook-Validierung).
---
*Erstellt durch den Curator.*

View File

@ -17,5 +17,13 @@ object ApiRoutes {
object Bewerbe {
fun abteilungen(bewerbId: Long): String = "$API_PREFIX/bewerbe/$bewerbId/abteilungen"
fun nennungen(bewerbId: Long): String = "$API_PREFIX/bewerbe/$bewerbId/nennungen"
}
object Masterdata {
const val REITER = "/api/masterdata/reiter"
const val PFERDE = "/api/masterdata/horse"
const val FUNKTIONAERE = "/api/masterdata/funktionaer"
const val VEREINE = "/api/masterdata/verein"
}
}

View File

@ -1,9 +1,11 @@
package at.mocode.turnier.feature.data.mapper
import at.mocode.turnier.feature.data.remote.dto.AbteilungDto
import at.mocode.turnier.feature.data.remote.dto.AbteilungsWarnungDto
import at.mocode.turnier.feature.data.remote.dto.BewerbDto
import at.mocode.turnier.feature.data.remote.dto.TurnierDto
import at.mocode.turnier.feature.domain.Abteilung
import at.mocode.turnier.feature.domain.AbteilungsWarnung
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.Turnier
@ -18,7 +20,14 @@ fun BewerbDto.toDomain(): Bewerb = Bewerb(
name = name,
sparte = sparte,
klasse = klasse,
nennungen = nennungen
nennungen = nennungen,
geplantesDatum = geplantesDatum,
beginnZeit = beginnZeit,
reitdauerMinuten = reitdauerMinuten,
umbauMinuten = umbauMinuten,
besichtigungMinuten = besichtigungMinuten,
austragungsplatzId = austragungsplatzId,
warnungen = warnungen.map { AbteilungsWarnung(it.code, it.nachricht, it.oetoParagraph) }
)
fun Bewerb.toDto(): BewerbDto = BewerbDto(
@ -29,7 +38,14 @@ fun Bewerb.toDto(): BewerbDto = BewerbDto(
name = name,
sparte = sparte,
klasse = klasse,
nennungen = nennungen
nennungen = nennungen,
geplantesDatum = geplantesDatum,
beginnZeit = beginnZeit,
reitdauerMinuten = reitdauerMinuten,
umbauMinuten = umbauMinuten,
besichtigungMinuten = besichtigungMinuten,
austragungsplatzId = austragungsplatzId,
warnungen = warnungen.map { AbteilungsWarnungDto(it.code, it.nachricht, it.oetoParagraph) }
)
fun AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name)

View File

@ -0,0 +1,49 @@
package at.mocode.turnier.feature.data.remote.dto
import kotlinx.serialization.Serializable
import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalUuidApi::class)
@Serializable
data class NennungSummaryDto(
val nennungId: String,
val turnierId: String,
val bewerbId: String,
val abteilungId: String,
val reiterId: String,
val pferdId: String,
val status: String,
val istNachnennung: Boolean,
val createdAt: String
)
@OptIn(ExperimentalUuidApi::class)
@Serializable
data class NennungDetailDto(
val nennungId: String,
val abteilungId: String,
val bewerbId: String,
val turnierId: String,
val reiterId: String,
val pferdId: String,
val zahlerId: String? = null,
val status: String,
val startwunsch: String,
val istNachnennung: Boolean,
val bemerkungen: String? = null,
val createdAt: String,
val updatedAt: String
)
@Serializable
data class NennungEinreichenRequest(
val abteilungId: String,
val bewerbId: String,
val turnierId: String,
val reiterId: String,
val pferdId: String,
val zahlerId: String? = null,
val startwunsch: String = "KEIN_WUNSCH",
val istNachnennung: Boolean = false,
val bemerkungen: String? = null
)

View File

@ -18,6 +18,20 @@ data class BewerbDto(
val sparte: String,
val klasse: String,
val nennungen: Int,
val geplantesDatum: String? = null,
val beginnZeit: String? = null,
val reitdauerMinuten: Int? = null,
val umbauMinuten: Int? = null,
val besichtigungMinuten: Int? = null,
val austragungsplatzId: String? = null,
val warnungen: List<AbteilungsWarnungDto> = emptyList(),
)
@Serializable
data class AbteilungsWarnungDto(
val code: String,
val nachricht: String,
val oetoParagraph: String? = null,
)
@Serializable

View File

@ -10,6 +10,13 @@ data class Bewerb(
val klasse: String,
val nennungen: Int,
val warnungen: List<AbteilungsWarnung> = emptyList(),
// Zeitplan-Felder
val geplantesDatum: String? = null, // ISO-Format
val beginnZeit: String? = null, // "HH:mm"
val reitdauerMinuten: Int? = null,
val umbauMinuten: Int? = null,
val besichtigungMinuten: Int? = null,
val austragungsplatzId: String? = null,
)
data class AbteilungsWarnung(
@ -18,11 +25,24 @@ data class AbteilungsWarnung(
val oetoParagraph: String?
)
data class AuditLogEntry(
val id: String,
val entityType: String,
val entityId: String,
val action: String,
val userId: String?,
val timestamp: String,
val changesJson: String?
)
interface BewerbRepository {
suspend fun list(turnierId: Long): Result<List<Bewerb>>
suspend fun getById(id: Long): Result<Bewerb>
suspend fun create(model: Bewerb): Result<Bewerb>
suspend fun update(id: Long, model: Bewerb): Result<Bewerb>
suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb>
suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>>
suspend fun exportZnsBSatz(turnierId: Long): Result<String>
suspend fun delete(id: Long): Result<Unit>
suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit>
}

View File

@ -0,0 +1,44 @@
package at.mocode.turnier.feature.domain
data class Reiter(
val id: String,
val vorname: String,
val nachname: String,
val satznummer: String? = null,
val verein: String? = null,
val feiId: String? = null,
val oepsNummer: String? = null
) {
val name: String get() = "$vorname $nachname"
}
data class Pferd(
val id: String,
val name: String,
val lebensnummer: String,
val geburtsjahr: Int? = null,
val oepsNummer: String? = null
)
data class Funktionaer(
val id: String,
val name: String,
val qualifikationen: List<String>,
val istAktiv: Boolean
)
data class Verein(
val id: String,
val name: String,
val vereinsNummer: String,
val ort: String?,
val istVeranstalter: Boolean
)
interface MasterdataRepository {
suspend fun searchReiter(query: String): Result<List<Reiter>>
suspend fun searchPferde(query: String): Result<List<Pferd>>
suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>>
suspend fun listVereine(): Result<List<Verein>>
suspend fun getVereinById(id: String): Result<Verein>
}

View File

@ -0,0 +1,25 @@
package at.mocode.turnier.feature.domain
data class Nennung(
val id: String,
val turnierId: String,
val bewerbId: String,
val abteilungId: String,
val reiterId: String,
val pferdId: String,
val status: String,
val istNachnennung: Boolean,
val createdAt: String,
// Erweiterte Infos für UI
val reiterName: String? = null,
val pferdeName: String? = null,
val bewerbName: String? = null
)
interface NennungRepository {
suspend fun list(turnierId: Long): Result<List<Nennung>>
suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>>
suspend fun einreichen(request: at.mocode.turnier.feature.data.remote.dto.NennungEinreichenRequest): Result<Nennung>
suspend fun updateStatus(id: String, status: String): Result<Nennung>
suspend fun delete(id: String): Result<Unit>
}

View File

@ -44,6 +44,11 @@ data class BewerbState(
val currentStartliste: List<StartlistenZeile> = emptyList(),
val discoveredNodes: List<DiscoveredService> = emptyList(),
val isScanning: Boolean = false,
// Zeitplan-Audit
val auditLog: List<at.mocode.turnier.feature.domain.AuditLogEntry> = emptyList(),
val isAuditLoading: Boolean = false,
val exportContent: String? = null,
val showExportDialog: Boolean = false,
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
)
@ -70,6 +75,10 @@ sealed interface BewerbIntent {
data object StartNetworkScan : 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
}
@ -160,6 +169,40 @@ class BewerbViewModel(
is BewerbIntent.StartNetworkScan -> startScan()
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}") }
}
}
}
private fun updateZeitplan(id: Long, beginn: String?) {
scope.launch {
repo.updateZeitplan(id, null, beginn, null).onSuccess {
load() // Neu laden um Konsistenz zu prüfen
}
}
}
@ -187,7 +230,7 @@ class BewerbViewModel(
// Für dieses MVP zeigen wir einfach an, dass wir scannen.
}
private fun generateStartliste() {
fun generateStartliste() {
val selectedId = _state.value.selectedId ?: return
reduce { it.copy(isLoading = true) }

View File

@ -0,0 +1,80 @@
package at.mocode.turnier.feature.presentation
import at.mocode.turnier.feature.data.remote.dto.NennungEinreichenRequest
import at.mocode.turnier.feature.domain.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class NennungenState(
val isLoading: Boolean = false,
val nennungen: List<Nennung> = emptyList(),
val searchResultsReiter: List<Reiter> = emptyList(),
val searchResultsPferde: List<Pferd> = emptyList(),
val errorMessage: String? = null
)
class NennungViewModel(
private val nennungRepo: NennungRepository,
private val masterdataRepo: MasterdataRepository,
private val turnierId: Long
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(NennungenState())
val state: StateFlow<NennungenState> = _state
init {
loadNennungen()
}
fun loadNennungen() {
_state.value = _state.value.copy(isLoading = true)
scope.launch {
nennungRepo.list(turnierId).onSuccess { list ->
_state.value = _state.value.copy(nennungen = list, isLoading = false)
}.onFailure {
_state.value = _state.value.copy(errorMessage = "Fehler beim Laden: ${it.message}", isLoading = false)
}
}
}
fun searchReiter(query: String) {
if (query.length < 2) return
scope.launch {
masterdataRepo.searchReiter(query).onSuccess { list ->
_state.value = _state.value.copy(searchResultsReiter = list)
}
}
}
fun searchPferde(query: String) {
if (query.length < 2) return
scope.launch {
masterdataRepo.searchPferde(query).onSuccess { list ->
_state.value = _state.value.copy(searchResultsPferde = list)
}
}
}
fun einreichen(bewerbId: String, abteilungId: String, reiterId: String, pferdId: String) {
_state.value = _state.value.copy(isLoading = true)
scope.launch {
val request = NennungEinreichenRequest(
abteilungId = abteilungId,
bewerbId = bewerbId,
turnierId = turnierId.toString(),
reiterId = reiterId,
pferdId = pferdId
)
nennungRepo.einreichen(request).onSuccess {
loadNennungen()
}.onFailure {
_state.value = _state.value.copy(errorMessage = "Fehler beim Einreichen: ${it.message}", isLoading = false)
}
}
}
}

View File

@ -4,6 +4,7 @@ import at.mocode.frontend.core.network.*
import at.mocode.turnier.feature.data.mapper.toDomain
import at.mocode.turnier.feature.data.mapper.toDto
import at.mocode.turnier.feature.data.remote.dto.BewerbDto
import at.mocode.turnier.feature.domain.AuditLogEntry
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.BewerbRepository
import io.ktor.client.*
@ -26,18 +27,19 @@ class DefaultBewerbRepository(
}
}
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit> = runCatching {
val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") {
setBody(bewerbe)
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit> =
runCatching {
val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") {
setBody(bewerbe)
}
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getById(id: Long): Result<Bewerb> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id")
@ -72,6 +74,47 @@ class DefaultBewerbRepository(
}
}
override suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> =
runCatching {
val response = client.patch("${ApiRoutes.API_PREFIX}/bewerbe/$id/zeitplan") {
contentType(ContentType.Application.Json)
setBody(
mapOf(
"geplantesDatum" to datum,
"beginnZeit" to beginn,
"austragungsplatzId" to platzId
)
)
}
when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/audit-log")
when {
response.status.isSuccess() -> response.body<List<AuditLogEntry>>()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun exportZnsBSatz(turnierId: Long): Result<String> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/turniere/$turnierId/export/zns/b-satz")
when {
response.status.isSuccess() -> response.body<String>()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id")
when {

View File

@ -0,0 +1,118 @@
package at.mocode.turnier.feature.data.remote
import at.mocode.frontend.core.network.ApiRoutes
import at.mocode.turnier.feature.domain.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
class DefaultMasterdataRepository(
private val client: HttpClient
) : MasterdataRepository {
override suspend fun searchReiter(query: String): Result<List<Reiter>> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.REITER}/search") {
parameter("q", query)
}
if (response.status.isSuccess()) {
// Wir mappen hier manuell, da die Features aktuell keine DTOs exportieren
response.body<List<ReiterApiDto>>().map { it.toDomain() }
} else emptyList()
}
override suspend fun searchPferde(query: String): Result<List<Pferd>> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.PFERDE}/search") {
parameter("q", query)
}
if (response.status.isSuccess()) {
response.body<List<HorseApiDto>>().map { it.toDomain() }
} else emptyList()
}
override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.FUNKTIONAERE}/search") {
parameter("q", query)
}
if (response.status.isSuccess()) {
response.body<List<FunktionaerApiDto>>().map {
Funktionaer(it.funktionaerId, it.name ?: "Unbekannt", it.qualifikationen, it.istAktiv)
}
} else emptyList()
}
override suspend fun listVereine(): Result<List<Verein>> = runCatching {
val response = client.get(ApiRoutes.Masterdata.VEREINE)
if (response.status.isSuccess()) {
response.body<List<VereinApiDto>>().map {
Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter)
}
} else emptyList()
}
override suspend fun getVereinById(id: String): Result<Verein> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.VEREINE}/$id")
if (response.status.isSuccess()) {
val it = response.body<VereinApiDto>()
Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter)
} else throw Exception("Verein nicht gefunden")
}
// Interne Hilfs-DTOs für das Mapping der Masterdata-API
@Serializable
private data class ReiterApiDto(
val reiterId: String,
val vorname: String,
val nachname: String,
val satznummer: String? = null,
val vereinsName: String? = null,
val feiId: String? = null,
val reiterLizenz: String? = null
) {
fun toDomain() = Reiter(
id = reiterId,
vorname = vorname,
nachname = nachname,
satznummer = satznummer,
verein = vereinsName,
feiId = feiId,
oepsNummer = satznummer
)
}
@Serializable
private data class HorseApiDto(
val pferdId: String,
val pferdeName: String,
val lebensnummer: String? = null,
val geschlecht: String,
val geburtsjahr: Int? = null,
val satznummer: String? = null
) {
fun toDomain() = Pferd(
id = pferdId,
name = pferdeName,
lebensnummer = lebensnummer ?: "",
geburtsjahr = geburtsjahr,
oepsNummer = satznummer
)
}
@Serializable
private data class FunktionaerApiDto(
val funktionaerId: String,
val name: String? = null,
val qualifikationen: List<String> = emptyList(),
val istAktiv: Boolean
)
@Serializable
private data class VereinApiDto(
val vereinId: String,
val vereinsNummer: String,
val name: String,
val ort: String? = null,
val istVeranstalter: Boolean
)
}

View File

@ -0,0 +1,88 @@
package at.mocode.turnier.feature.data.remote
import at.mocode.frontend.core.network.*
import at.mocode.turnier.feature.data.remote.dto.*
import at.mocode.turnier.feature.domain.Nennung
import at.mocode.turnier.feature.domain.NennungRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class DefaultNennungRepository(
private val client: HttpClient
) : NennungRepository {
override suspend fun list(turnierId: Long): Result<List<Nennung>> = runCatching {
val response = client.get("${ApiRoutes.Turniere.ROOT}/$turnierId/nennungen")
if (response.status.isSuccess()) {
response.body<List<NennungSummaryDto>>().map { it.toDomain() }
} else {
throw HttpError(response.status.value)
}
}
override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = runCatching {
val response = client.get(ApiRoutes.Bewerbe.nennungen(bewerbId))
if (response.status.isSuccess()) {
response.body<List<NennungSummaryDto>>().map { it.toDomain() }
} else {
throw HttpError(response.status.value)
}
}
override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> = runCatching {
val response = client.post(ApiRoutes.Bewerbe.nennungen(request.bewerbId.toLong())) {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
response.body<NennungDetailDto>().toDomain()
} else {
throw HttpError(response.status.value)
}
}
override suspend fun updateStatus(id: String, status: String): Result<Nennung> = runCatching {
val response = client.patch("${ApiRoutes.API_PREFIX}/nennungen/$id/status") {
contentType(ContentType.Application.Json)
setBody(mapOf("status" to status))
}
if (response.status.isSuccess()) {
response.body<NennungDetailDto>().toDomain()
} else {
throw HttpError(response.status.value)
}
}
override suspend fun delete(id: String): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.API_PREFIX}/nennungen/$id")
if (!response.status.isSuccess()) {
throw HttpError(response.status.value)
}
}
private fun NennungSummaryDto.toDomain() = Nennung(
id = nennungId,
turnierId = turnierId,
bewerbId = bewerbId,
abteilungId = abteilungId,
reiterId = reiterId,
pferdId = pferdId,
status = status,
istNachnennung = istNachnennung,
createdAt = createdAt
)
private fun NennungDetailDto.toDomain() = Nennung(
id = nennungId,
turnierId = turnierId,
bewerbId = bewerbId,
abteilungId = abteilungId,
reiterId = reiterId,
pferdId = pferdId,
status = status,
istNachnennung = istNachnennung,
createdAt = createdAt
)
}

View File

@ -1,18 +1,12 @@
package at.mocode.turnier.feature.di
import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.turnier.feature.data.remote.DefaultAbteilungRepository
import at.mocode.turnier.feature.data.remote.DefaultBewerbRepository
import at.mocode.turnier.feature.data.remote.DefaultStartlistenRepository
import at.mocode.turnier.feature.data.remote.DefaultTurnierRepository
import at.mocode.turnier.feature.data.remote.*
import at.mocode.turnier.feature.domain.AbteilungRepository
import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.StartlistenRepository
import at.mocode.turnier.feature.domain.TurnierRepository
import at.mocode.turnier.feature.presentation.AbteilungViewModel
import at.mocode.turnier.feature.presentation.BewerbAnlegenViewModel
import at.mocode.turnier.feature.presentation.BewerbViewModel
import at.mocode.turnier.feature.presentation.TurnierViewModel
import at.mocode.turnier.feature.presentation.*
import org.koin.core.qualifier.named
import org.koin.dsl.module
@ -22,6 +16,8 @@ val turnierFeatureModule = module {
single<BewerbRepository> { DefaultBewerbRepository(client = get(qualifier = named("apiClient"))) }
single<AbteilungRepository> { DefaultAbteilungRepository(client = get(qualifier = named("apiClient"))) }
single<StartlistenRepository> { DefaultStartlistenRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.turnier.feature.domain.NennungRepository> { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) }
single<at.mocode.turnier.feature.domain.MasterdataRepository> { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) }
// ViewModels
factory { TurnierViewModel(repo = get()) }
@ -40,4 +36,12 @@ val turnierFeatureModule = module {
factory { (bewerbId: Long, abteilungsNr: Int) ->
AbteilungViewModel(repo = get(), bewerbId = bewerbId, abteilungsNr = abteilungsNr)
}
factory { (turnierId: Long) ->
NennungViewModel(
nennungRepo = get(),
masterdataRepo = get(),
turnierId = turnierId
)
}
}

View File

@ -0,0 +1,65 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val SeriesBlue = Color(0xFF1E3A8A)
/**
* SERIES-Screen gemäß Vision_03 & Phase 10.
*
* Zeigt Cups, Serien und Meisterschaften mit konfigurierbaren Reglements.
*/
@Composable
fun SeriesScreen(
title: String,
onBack: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text("Konfiguration & Auswertung (Phase 10)", fontSize = 13.sp, color = Color.Gray)
}
Button(
onClick = { /* Neu anlegen Dialog */ },
colors = ButtonDefaults.buttonColors(containerColor = SeriesBlue)
) {
Text("Neue Serie anlegen")
}
}
HorizontalDivider()
// Leere Liste (Placeholder)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine $title konfiguriert", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text(
"Verknüpfe Bewerbe zu einer Serie, um Punktestände automatisch zu berechnen.",
fontSize = 13.sp,
color = Color.Gray,
modifier = Modifier.padding(horizontal = 32.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onBack) {
Text("Zurück zur Verwaltung")
}
}
}
}
}

View File

@ -1,6 +1,5 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@ -11,6 +10,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.koin.compose.koinInject
import org.koin.core.parameter.parametersOf
/**
* Detailansicht eines Turniers gemäß Vision_03.
@ -56,10 +57,13 @@ fun TurnierDetailScreen(
"ARTIKEL",
"ABRECHNUNG",
"NENNUNGEN",
"ZEITPLAN",
"STARTLISTEN",
"ERGEBNISLISTEN",
)
val bewerbViewModel: BewerbViewModel = koinInject { parametersOf(turnierId) }
Column(modifier = Modifier.fillMaxSize()) {
// Horizontale Tab-Bar (direkt unter der TopBar)
PrimaryScrollableTabRow(
@ -98,15 +102,23 @@ fun TurnierDetailScreen(
veranstalterBundesland = veranstalterBundesland,
veranstalterLogoUrl = veranstalterLogoUrl,
)
1 -> OrganisationTabContent()
2 -> Box(modifier = Modifier.fillMaxSize()) {
Text("BEWERBE Tab (Anbindung in Arbeit)", modifier = Modifier.align(Alignment.Center))
1 -> {
val nennungViewModel = koinInject<NennungViewModel>(parameters = { parametersOf(turnierId) })
OrganisationTabContent(viewModel = nennungViewModel)
}
2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId)
3 -> ArtikelTabContent()
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
5 -> NennungenTabContent(onAbrechnungClick = { selectedTab = 4 })
6 -> StartlistenTabContent()
7 -> ErgebnislistenTabContent()
5 -> {
val nennungViewModel = koinInject<NennungViewModel>(parameters = { parametersOf(turnierId) })
NennungenTabContent(
viewModel = nennungViewModel,
onAbrechnungClick = { selectedTab = 4 }
)
}
6 -> ZeitplanTabContent(turnierId = turnierId, viewModel = bewerbViewModel)
7 -> StartlistenTabContent()
8 -> ErgebnislistenTabContent()
}
}
}
@ -116,4 +128,5 @@ fun TurnierDetailScreen(
// TurnierBewerbeTab.kt → BewerbeTabContent()
// TurnierNennungenTab.kt → NennungenTabContent()
// TurnierStartlistenTab.kt → StartlistenTabContent()
// TurnierZeitplanTab.kt → ZeitplanTabContent()
// TurnierErgebnislistenTab.kt → ErgebnislistenTabContent()

View File

@ -10,6 +10,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.turnier.feature.domain.Bewerb
import org.koin.compose.koinInject
private val ElBlue = Color(0xFF1E3A8A)
private val ElHeaderBg = Color(0xFFF1F5F9)
@ -22,11 +24,19 @@ private val ElHeaderBg = Color(0xFFF1F5F9)
* - Rechts (280dp): Platzierung & Geldpreis-Panel
*/
@Composable
fun ErgebnislistenTabContent() {
fun ErgebnislistenTabContent(
viewModel: BewerbViewModel = koinInject()
) {
val state by viewModel.state.collectAsState()
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Bewerbs-Tabs + Tabelle ─────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
ErgebnislistenBewerbsTabs()
ErgebnislistenBewerbsTabs(
bewerbe = state.list,
selectedId = state.selectedId,
onSelect = { viewModel.send(BewerbIntent.Select(it)) }
)
}
VerticalDivider()
@ -37,28 +47,31 @@ fun ErgebnislistenTabContent() {
}
@Composable
private fun ErgebnislistenBewerbsTabs() {
val bewerbe = remember {
listOf("Bewerb 1", "Bewerb 2", "Bewerb 3", "Bewerb 4", "Bewerb 5")
}
var selectedBewerb by remember { mutableIntStateOf(0) }
private fun ErgebnislistenBewerbsTabs(
bewerbe: List<Bewerb>,
selectedId: Long?,
onSelect: (Long?) -> Unit
) {
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
PrimaryScrollableTabRow(
selectedTabIndex = selectedBewerb,
selectedTabIndex = selectedIndex,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = ElBlue,
edgePadding = 0.dp,
) {
bewerbe.forEachIndexed { index, title ->
bewerbe.forEachIndexed { index, bewerb ->
Tab(
selected = selectedBewerb == index,
onClick = { selectedBewerb = index },
text = { Text(title, fontSize = 12.sp) },
selected = selectedId == bewerb.id,
onClick = { onSelect(bewerb.id) },
text = { Text(bewerb.tag, fontSize = 12.sp) },
)
}
}
HorizontalDivider()
val selectedBewerb = bewerbe.getOrNull(selectedIndex)
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
@ -66,7 +79,7 @@ private fun ErgebnislistenBewerbsTabs() {
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Bewerb ${selectedBewerb + 1} Ergebnisliste",
text = selectedBewerb?.let { "${it.tag} ${it.name}" } ?: "Kein Bewerb ausgewählt",
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
)

View File

@ -30,13 +30,18 @@ private val NennSelectedBg = Color(0xFFEFF6FF)
* - Rechts (360dp): Verkauf/Buchungen + Bewerbsübersicht
*/
@Composable
fun NennungenTabContent(onAbrechnungClick: () -> Unit = {}) {
fun NennungenTabContent(
viewModel: NennungViewModel,
onAbrechnungClick: () -> Unit = {}
) {
val state by viewModel.state.collectAsState()
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Suche + Tabelle ─────────────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
NennungenSuchePanel()
NennungenSuchePanel(viewModel, state)
HorizontalDivider()
NennungenTabelle()
NennungenTabelle(viewModel, state)
}
VerticalDivider()
@ -56,40 +61,48 @@ fun NennungenTabContent(onAbrechnungClick: () -> Unit = {}) {
}
@Composable
private fun NennungenSuchePanel() {
private fun NennungenSuchePanel(viewModel: NennungViewModel, state: NennungenState) {
var pferdQuery by remember { mutableStateOf("") }
var reiterQuery by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Pferd & Reiter suchen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = "",
onValueChange = {},
value = pferdQuery,
onValueChange = {
pferdQuery = it
viewModel.searchPferde(it)
},
placeholder = { Text("Pferd suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) },
modifier = Modifier.weight(1f).height(44.dp),
singleLine = true,
)
OutlinedTextField(
value = "",
onValueChange = {},
value = reiterQuery,
onValueChange = {
reiterQuery = it
viewModel.searchReiter(it)
},
placeholder = { Text("Reiter suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) },
modifier = Modifier.weight(1f).height(44.dp),
singleLine = true,
)
Button(
onClick = {},
onClick = { /* In einem echten Dialog würde hier die Auswahl kombiniert */ },
colors = ButtonDefaults.buttonColors(containerColor = NennBlue),
modifier = Modifier.height(44.dp),
) {
Text("Suchen", fontSize = 12.sp)
Text("Nennen", fontSize = 12.sp)
}
}
}
}
@Composable
private fun NennungenTabelle() {
val nennungen = remember { sampleNennungen() }
private fun NennungenTabelle(viewModel: NennungViewModel, state: NennungenState) {
var selectedIndex by remember { mutableIntStateOf(-1) }
Column(modifier = Modifier.fillMaxSize()) {
@ -100,15 +113,18 @@ private fun NennungenTabelle() {
.background(NennHeaderBg)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
Text("ID", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Bewerb", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Status", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp))
}
HorizontalDivider()
if (nennungen.isEmpty()) {
if (state.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (state.nennungen.isEmpty() && !state.isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine Nennungen vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
@ -122,7 +138,7 @@ private fun NennungenTabelle() {
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(nennungen) { index, nennung ->
itemsIndexed(state.nennungen) { index, nennung ->
Row(
modifier = Modifier
.fillMaxWidth()
@ -132,15 +148,14 @@ private fun NennungenTabelle() {
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${nennung.startnr}",
nennung.id.takeLast(6),
fontSize = 12.sp,
modifier = Modifier.width(60.dp),
color = NennBlue,
fontWeight = FontWeight.Bold
)
Text(nennung.pferd, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(nennung.reiter, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(nennung.bewerb, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(nennung.pferdId.takeLast(8), fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(nennung.reiterId.takeLast(8), fontSize = 12.sp, modifier = Modifier.weight(1f))
NennungStatusBadge(nennung.status)
}
HorizontalDivider(color = Color(0xFFE5E7EB))

View File

@ -29,7 +29,9 @@ private val DeleteRed = Color(0xFFDC2626)
* - Austragungsplätze: dynamische Liste (Sparte, Größe, Bezeichnung, Löschen)
*/
@Composable
fun OrganisationTabContent() {
fun OrganisationTabContent(viewModel: NennungViewModel) {
val state by viewModel.state.collectAsState()
var turnierleiter by remember { mutableStateOf("") }
var turnierbeauftragter by remember { mutableStateOf("") }
var technischerDelegierter by remember { mutableStateOf("") }
@ -66,7 +68,10 @@ fun OrganisationTabContent() {
// ── Funktionäre & Offizielle ─────────────────────────────────────────
OrgSectionCard(title = "Funktionäre & Offizielle (C-Satz)") {
OrgSubSection("Turnier-Organisation") {
OrgSearchField("Turnierleiter:", turnierleiter) { turnierleiter = it }
OrgSearchField("Turnierleiter:", turnierleiter) {
turnierleiter = it
// In einem echten Szenario würde hier die Masterdata-Suche getriggert
}
OrgSearchField("Turnierbeauftragter:", turnierbeauftragter) { turnierbeauftragter = it }
OrgSearchField("Technischer Delegierter:", technischerDelegierter) { technischerDelegierter = it }
OrgSearchField("Parcourschef:", parcourschef) { parcourschef = it }

View File

@ -10,6 +10,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.turnier.feature.domain.Bewerb
import org.koin.compose.koinInject
private val SlBlue = Color(0xFF1E3A8A)
private val SlHeaderBg = Color(0xFFF1F5F9)
@ -22,11 +24,22 @@ private val SlHeaderBg = Color(0xFFF1F5F9)
* - Rechts (280dp): Sortierung & Zeit-Panel
*/
@Composable
fun StartlistenTabContent() {
fun StartlistenTabContent(
viewModel: BewerbViewModel = koinInject()
) {
val state by viewModel.state.collectAsState()
val selectedBewerb = state.list.find { it.id == state.selectedId }
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Bewerbs-Tabs + Tabelle ─────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
StartlistenBewerbsTabs()
StartlistenBewerbsTabs(
bewerbe = state.list,
selectedId = state.selectedId,
onSelect = { viewModel.send(BewerbIntent.Select(it)) },
currentStartliste = state.currentStartliste,
onGenerate = { viewModel.generateStartliste() }
)
}
VerticalDivider()
@ -37,28 +50,33 @@ fun StartlistenTabContent() {
}
@Composable
private fun StartlistenBewerbsTabs() {
val bewerbe = remember {
listOf("Bewerb 1", "Bewerb 2", "Bewerb 3", "Bewerb 4", "Bewerb 5")
}
var selectedBewerb by remember { mutableIntStateOf(0) }
private fun StartlistenBewerbsTabs(
bewerbe: List<Bewerb>,
selectedId: Long?,
onSelect: (Long?) -> Unit,
currentStartliste: List<StartlistenZeile>,
onGenerate: () -> Unit
) {
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
PrimaryScrollableTabRow(
selectedTabIndex = selectedBewerb,
selectedTabIndex = selectedIndex,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = SlBlue,
edgePadding = 0.dp,
) {
bewerbe.forEachIndexed { index, title ->
bewerbe.forEachIndexed { index, bewerb ->
Tab(
selected = selectedBewerb == index,
onClick = { selectedBewerb = index },
text = { Text(title, fontSize = 12.sp) },
selected = selectedId == bewerb.id,
onClick = { onSelect(bewerb.id) },
text = { Text(bewerb.tag, fontSize = 12.sp) },
)
}
}
HorizontalDivider()
val selectedBewerb = bewerbe.getOrNull(selectedIndex)
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
@ -66,7 +84,7 @@ private fun StartlistenBewerbsTabs() {
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Bewerb ${selectedBewerb + 1} Startliste",
text = selectedBewerb?.let { "${it.tag} ${it.name}" } ?: "Kein Bewerb ausgewählt",
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
)
@ -99,20 +117,40 @@ private fun StartlistenBewerbsTabs() {
}
HorizontalDivider()
// Leere Liste
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine Starter vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(8.dp))
Text("Startliste wird nach Nennungsschluss generiert.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
Spacer(Modifier.height(16.dp))
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
) {
Text("Startliste generieren", fontSize = 13.sp)
if (currentStartliste.isEmpty()) {
// Leere Liste
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine Starter vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(8.dp))
Text("Startliste wird nach Nennungsschluss generiert.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
Spacer(Modifier.height(16.dp))
Button(
onClick = onGenerate,
colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
) {
Text("Startliste generieren", fontSize = 13.sp)
}
}
}
} else {
// Liste anzeigen
androidx.compose.foundation.lazy.LazyColumn(modifier = Modifier.fillMaxSize()) {
items(currentStartliste.size) { index ->
val zeile = currentStartliste[index]
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(zeile.nr.toString(), fontSize = 12.sp, modifier = Modifier.width(70.dp))
Text(zeile.pferd, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(zeile.reiter, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text("-", fontSize = 12.sp, modifier = Modifier.width(80.dp))
Text(zeile.zeit, fontSize = 12.sp, modifier = Modifier.width(70.dp))
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
}
}

View File

@ -0,0 +1,412 @@
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
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt
private val ZeitplanBlue = Color(0xFF1E3A8A)
private val ZeitplanBg = Color(0xFFF8FAFC)
private val SlotBorder = Color(0xFFE2E8F0)
private val HourLabelColor = Color(0xFF64748B)
// Konfiguration für den Zeitstrahl
private const val START_HOUR = 7
private const val END_HOUR = 20
private val HOUR_HEIGHT = 80.dp
private val MINUTE_HEIGHT = HOUR_HEIGHT / 60
/**
* ZEITPLAN-Tab gemäß Konzept Zeitplan-Optimierung.
*
* Visuelle Kalender-Ansicht mit Drag & Drop Support.
*/
@Composable
fun ZeitplanTabContent(
turnierId: Long,
viewModel: BewerbViewModel
) {
val state by viewModel.state.collectAsState()
val items = state.filtered.map { bewerb ->
val startMin = if (bewerb.beginnZeit != null) {
val parts = bewerb.beginnZeit.split(":")
parts[0].toInt() * 60 + parts[1].toInt()
} else {
7 * 60 // Default 07:00 wenn nichts gesetzt
}
ZeitplanItemUi(
id = bewerb.id,
nummer = bewerb.tag.filter { it.isDigit() }.toIntOrNull() ?: 0,
name = bewerb.name,
startMinutes = startMin,
durationMinutes = bewerb.reitdauerMinuten ?: 60,
color = when (bewerb.sparte) {
"DRESSUR" -> Color(0xFF1E3A8A)
"SPRINGEN" -> Color(0xFF059669)
else -> ZeitplanBlue
},
hasConflict = bewerb.warnungen.isNotEmpty(),
conflictMessage = bewerb.warnungen.joinToString("\n") { it.nachricht }
)
}
val scrollState = rememberScrollState()
var showAuditLog by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) {
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()
// 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))
},
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(
viewModel: BewerbViewModel,
onShowHistory: () -> Unit = {}
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
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") })
AssistChip(onClick = {}, label = { Text("Viereck 1") }, leadingIcon = { Text("", fontSize = 12.sp) })
Spacer(Modifier.width(12.dp))
Button(
onClick = { viewModel.send(BewerbIntent.ExportZnsBSatz) },
colors = ButtonDefaults.buttonColors(containerColor = ZeitplanBlue)
) {
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
)
}
}
}
@Composable
private fun ZeitAchse() {
Column(
modifier = Modifier
.width(60.dp)
.fillMaxHeight()
.background(Color.White)
) {
Box(modifier = Modifier.fillMaxHeight().width(59.dp).background(Color.White)) {
Column {
for (hour in START_HOUR..END_HOUR) {
Box(
modifier = Modifier.height(HOUR_HEIGHT).fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
Text(
text = "${hour.toString().padStart(2, '0')}:00",
fontSize = 11.sp,
color = HourLabelColor,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
}
}
@Composable
private fun ZeitplanGitter() {
Column {
for (hour in START_HOUR..END_HOUR) {
Box(
modifier = Modifier
.height(HOUR_HEIGHT)
.fillMaxWidth()
) {
HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter), color = SlotBorder)
}
}
}
}
@Composable
private fun DraggableBewerbBox(
item: ZeitplanItemUi,
onPositionChange: (Int) -> Unit,
onClick: () -> Unit = {}
) {
// Berechnung der Position basierend auf den Startminuten seit START_HOUR
val relativeMinutes = item.startMinutes - (START_HOUR * 60)
val topOffset = (relativeMinutes * MINUTE_HEIGHT.value).dp
val height = (item.durationMinutes * MINUTE_HEIGHT.value).dp
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.offset(y = topOffset)
.padding(horizontal = 8.dp)
.fillMaxWidth()
.height(height)
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.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 = {
// Snapping auf 5 Minuten Intervalle
val movedMinutes = (offsetY / MINUTE_HEIGHT.toPx()).roundToInt()
val newTotalMinutes = item.startMinutes + movedMinutes
val snappedMinutes = (newTotalMinutes / 5) * 5
onPositionChange(snappedMinutes)
offsetX = 0f
offsetY = 0f
},
onDrag = { change, dragAmount ->
change.consume()
// Nur vertikales Dragging für den Zeitplan vorerst
offsetY += dragAmount.y
}
)
}
.padding(8.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.timeString,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = item.color
)
Spacer(Modifier.width(8.dp))
Text(
text = "Bewerb ${item.nummer}",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
}
Text(
text = item.name,
fontSize = 12.sp,
maxLines = 1,
color = Color.DarkGray
)
if (item.hasConflict) {
Row(
modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
Text("⚠️", fontSize = 12.sp)
Spacer(Modifier.width(4.dp))
Text(
text = item.conflictMessage.ifEmpty { "Konflikt" },
fontSize = 10.sp,
color = Color.Red,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
}
}
}
data class ZeitplanItemUi(
val id: Long,
val nummer: Int,
val name: String,
val startMinutes: Int, // Minuten seit 00:00
val durationMinutes: Int,
val color: Color = ZeitplanBlue,
val hasConflict: Boolean = false,
val conflictMessage: String = ""
) {
val timeString: String
get() {
val h = startMinutes / 60
val m = startMinutes % 60
return "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}"
}
}
private fun sampleZeitplanItems() = listOf(
ZeitplanItemUi(1, 1, "Dressurreiterprüfung Reiterpass", 8 * 60, 45),
ZeitplanItemUi(2, 2, "Dressurreiterprüfung Reitenadel", 8 * 60 + 50, 60, hasConflict = true),
ZeitplanItemUi(3, 3, "Dressurprüfung Kl. A (Aufgabe A2)", 10 * 60 + 30, 90, color = Color(0xFF059669)),
ZeitplanItemUi(4, 4, "Mittagspause", 12 * 60 + 30, 45, color = Color(0xFFD97706)),
ZeitplanItemUi(5, 5, "Dressurreiterprüfung Kl. L", 13 * 60 + 30, 120, color = Color(0xFF7C3AED)),
)

View File

@ -41,6 +41,8 @@ fun AdminUebersichtScreen(
onVeranstaltungOeffnen: (Long) -> Unit,
onPingService: () -> Unit = {},
onVereineOeffnen: () -> Unit = {},
onMeisterschaftenOeffnen: () -> Unit = {},
onCupsOeffnen: () -> Unit = {},
) {
// Placeholder-Daten für die UI-Struktur (sichtbar als Cards)
val sample = listOf(
@ -68,7 +70,9 @@ fun AdminUebersichtScreen(
inVorbereitung = 0,
gesamt = 0,
archiv = 0,
onVereineClick = onVereineOeffnen
onVereineClick = onVereineOeffnen,
onMeisterschaftenClick = onMeisterschaftenOeffnen,
onCupsClick = onCupsOeffnen
)
// Toolbar
@ -159,6 +163,8 @@ private fun KpiKachelRow(
gesamt: Int,
archiv: Int,
onVereineClick: () -> Unit = {},
onMeisterschaftenClick: () -> Unit = {},
onCupsClick: () -> Unit = {},
) {
Row(
modifier = Modifier
@ -178,18 +184,24 @@ private fun KpiKachelRow(
akzentFarbe = Color(0xFF3B82F6),
modifier = Modifier.weight(1f),
)
KpiKachel(
label = "MEISTERSCHAFTEN",
wert = "-",
akzentFarbe = Color(0xFF1E3A8A),
modifier = Modifier.weight(1f).clickable { onMeisterschaftenClick() },
)
KpiKachel(
label = "CUPS",
wert = "-",
akzentFarbe = Color(0xFF1E3A8A),
modifier = Modifier.weight(1f).clickable { onCupsClick() },
)
KpiKachel(
label = "VEREINE",
wert = "4", // Mock
akzentFarbe = Color(0xFF6B7280),
modifier = Modifier.weight(1f).clickable { onVereineClick() },
)
KpiKachel(
label = "ARCHIV",
wert = archiv.toString(),
akzentFarbe = Color(0xFF9CA3AF),
modifier = Modifier.weight(1f),
)
}
}

View File

@ -33,6 +33,7 @@ import at.mocode.frontend.features.verein.presentation.VereinScreen
import at.mocode.frontend.features.verein.presentation.VereinViewModel
import at.mocode.ping.feature.presentation.PingScreen
import at.mocode.ping.feature.presentation.PingViewModel
import at.mocode.turnier.feature.presentation.SeriesScreen
import at.mocode.turnier.feature.presentation.TurnierDetailScreen
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
@ -302,6 +303,24 @@ private fun DesktopTopBar(
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.Meisterschaften -> {
BreadcrumbSeparator()
Text(
text = "Meisterschaften",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.Cups -> {
BreadcrumbSeparator()
Text(
text = "Cups",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
else -> {}
}
}
@ -686,6 +705,14 @@ private fun DesktopContentArea(
)
}
is AppScreen.Meisterschaften -> {
SeriesScreen(title = "Meisterschaften", onBack = onBack)
}
is AppScreen.Cups -> {
SeriesScreen(title = "Cups", onBack = onBack)
}
is AppScreen.Nennung -> {
val nennungViewModel: NennungViewModel = koinViewModel()
NennungsMaske(
@ -698,6 +725,8 @@ private fun DesktopContentArea(
else -> AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) },
onMeisterschaftenOeffnen = { onNavigate(AppScreen.Meisterschaften) },
onCupsOeffnen = { onNavigate(AppScreen.Cups) }
)
}
}

View File

@ -2,10 +2,9 @@ package at.mocode.desktop.screens.preview
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.StartlistenRepository
import at.mocode.turnier.feature.domain.*
import at.mocode.turnier.feature.presentation.*
import at.mocode.turnier.feature.data.remote.dto.NennungEinreichenRequest
import at.mocode.zns.parser.ZnsBewerb
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterAuswahlScreen
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailScreen
@ -108,8 +107,23 @@ fun PreviewTurnierStammdatenTab() {
@ComponentPreview
@Composable
fun PreviewTurnierOrganisationTab() {
val mockNennungRepo = object : NennungRepository {
override suspend fun list(turnierId: Long): Result<List<Nennung>> = Result.success(emptyList())
override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = Result.success(emptyList())
override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> = Result.failure(NotImplementedError())
override suspend fun updateStatus(id: String, status: String): Result<Nennung> = Result.failure(NotImplementedError())
override suspend fun delete(id: String): Result<Unit> = Result.success(Unit)
}
val mockMasterdataRepo = object : MasterdataRepository {
override suspend fun searchReiter(query: String): Result<List<Reiter>> = Result.success(emptyList())
override suspend fun searchPferde(query: String): Result<List<Pferd>> = Result.success(emptyList())
override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = Result.success(emptyList())
override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList())
override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError())
}
val vm = NennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
MaterialTheme {
OrganisationTabContent()
OrganisationTabContent(viewModel = vm)
}
}
@ -122,6 +136,9 @@ fun PreviewTurnierBewerbeTab() {
override suspend fun create(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 updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> = Result.failure(NotImplementedError())
override suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>> = Result.success(emptyList())
override suspend fun exportZnsBSatz(turnierId: Long): Result<String> = Result.success("BBEWERBE\r\n B0100Bewerb 1 A01 20260411001\r\n")
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit)
}
val mockStartlistenRepo = object : StartlistenRepository {
@ -158,8 +175,23 @@ fun PreviewTurnierAbrechnungTab() {
@ComponentPreview
@Composable
fun PreviewTurnierNennungenTab() {
val mockNennungRepo = object : NennungRepository {
override suspend fun list(turnierId: Long): Result<List<Nennung>> = Result.success(emptyList())
override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = Result.success(emptyList())
override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> = Result.failure(NotImplementedError())
override suspend fun updateStatus(id: String, status: String): Result<Nennung> = Result.failure(NotImplementedError())
override suspend fun delete(id: String): Result<Unit> = Result.success(Unit)
}
val mockMasterdataRepo = object : MasterdataRepository {
override suspend fun searchReiter(query: String): Result<List<Reiter>> = Result.success(emptyList())
override suspend fun searchPferde(query: String): Result<List<Pferd>> = Result.success(emptyList())
override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = Result.success(emptyList())
override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList())
override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError())
}
val vm = NennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
MaterialTheme {
NennungenTabContent()
NennungenTabContent(viewModel = vm)
}
}

View File

@ -70,6 +70,7 @@ include(":backend:infrastructure:security")
include(":backend:infrastructure:zns-importer")
// === BACKEND - SERVICES ===
// --- ENTRIES (Nennungen) ---
include(":backend:services:entries:entries-api")
include(":backend:services:entries:entries-domain")
@ -95,7 +96,7 @@ include(":backend:services:masterdata:masterdata-infrastructure")
include(":backend:services:masterdata:masterdata-service")
// --- BILLING (Kassa, Zahlungen & Rechnungen) ---
include(":backend:services:billing:billing-api")
// include(":backend:services:billing:billing-api")
include(":backend:services:billing:billing-domain")
include(":backend:services:billing:billing-service")
@ -131,7 +132,6 @@ include(":frontend:core:local-db")
include(":frontend:core:sync")
// --- FEATURES ---
// include(":frontend:features:members-feature")
include(":frontend:features:ping-feature")
include(":frontend:features:nennung-feature")
include(":frontend:features:zns-import-feature")