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 ### Hinzugefügt
- **Bugfix**: Behebung von Build-Fehlern im `veranstalter-feature` nach der Paket-Konsolidierung. - **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 */ /** Vorgeschriebene Abteilungs-Struktur nicht vorhanden */
WARN_STRUKTURELLE_TEILUNG_FEHLT, WARN_STRUKTURELLE_TEILUNG_FEHLT,
/** Abteilungs-Struktur vorhanden, aber Teilnehmerkreis falsch/unvollständig */ /** 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 package at.mocode.entries.domain.service
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.Bewerb
import at.mocode.entries.domain.repository.CompetitionRepository import at.mocode.entries.domain.repository.CompetitionRepository
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@ -46,9 +48,80 @@ class CompetitionWarningService(
} }
} }
// 4. Zeitplan-Konflikte (Turnier-weit)
result.putAll(validateZeitplanKonflikte(bewerbe))
return result 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. * Validiert einen einzelnen Bewerb und gibt Warnungen zurück.
*/ */

View File

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

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.repository.NennungRepository
import at.mocode.entries.domain.service.CompetitionWarningService 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.TurnierTable import at.mocode.entries.service.persistence.TurnierTable
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,
@ -156,15 +169,44 @@ 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,
austragungsplatzId = req.austragungsplatzId?.let { Uuid.parse(it) } 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) 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")

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

View File

@ -171,7 +171,7 @@ subprojects {
buildUponDefaultConfig = true buildUponDefaultConfig = true
allRules = false allRules = false
autoCorrect = 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 basePath = rootDir.absolutePath
} }
tasks.withType<Detekt>().configureEach { tasks.withType<Detekt>().configureEach {

View File

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

View File

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

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] **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. ✓ * [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.* *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] **Rulebook-Check:** ÖTO §43 "Parcoursbesichtigung zu Pferd" eingearbeitet. ✓
* [x] **Feature-Migration:** Pferde-, Reiter-, Funktionärs- und Veranstalter-Module vollständig auf KMP umgestellt. ✓ * [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. ✓ * [x] **Cleanup:** `FRONTEND_CLEANUP_TODO.md` für Migration von `v2` Screens weitestgehend abgeschlossen. ✓
* [ ] **Zeitplan:** Dynamische Verschiebung von Bewerben (Drag & Drop im Kalender). * [x] **Zeitplan:** Dynamische Verschiebung von Bewerben (Drag & Drop im Kalender).
* [ ] **Protokoll:** Implementierung eines Event-Logs für manuelle Eingriffe in Startlisten. * [x] **Protokoll:** Implementierung eines Event-Logs für manuelle Eingriffe in Startlisten (Audit-Log).
* [ ] **Export:** Startlisten-Export für ZNS (XML-B-Satz). * [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.* *Ziel: Cups, Serien und Meisterschaften mit konfigurierbaren Reglements.*
* [ ] **`series-context`:** Pluggable Berechnungsmodell, konfigurierbare Paar-Bindung. * [ ] **`series-context`:** Pluggable Berechnungsmodell, konfigurierbare Paar-Bindung.
* [ ] **Web-Portal:** Shared Module aus Desktop-App extrahieren → Web-Portal aufbauen. * [ ] **Web-Portal:** Shared Module aus Desktop-App extrahieren → Web-Portal aufbauen.
* [ ] **Mobile:** KMP-Sharing auf Android/iOS ausweiten. * [ ] **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 { object Bewerbe {
fun abteilungen(bewerbId: Long): String = "$API_PREFIX/bewerbe/$bewerbId/abteilungen" 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 package at.mocode.turnier.feature.data.mapper
import at.mocode.turnier.feature.data.remote.dto.AbteilungDto 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.BewerbDto
import at.mocode.turnier.feature.data.remote.dto.TurnierDto import at.mocode.turnier.feature.data.remote.dto.TurnierDto
import at.mocode.turnier.feature.domain.Abteilung 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.Bewerb
import at.mocode.turnier.feature.domain.Turnier import at.mocode.turnier.feature.domain.Turnier
@ -18,7 +20,14 @@ fun BewerbDto.toDomain(): Bewerb = Bewerb(
name = name, name = name,
sparte = sparte, sparte = sparte,
klasse = klasse, 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( fun Bewerb.toDto(): BewerbDto = BewerbDto(
@ -29,7 +38,14 @@ fun Bewerb.toDto(): BewerbDto = BewerbDto(
name = name, name = name,
sparte = sparte, sparte = sparte,
klasse = klasse, 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) 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 sparte: String,
val klasse: String, val klasse: String,
val nennungen: Int, 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 @Serializable

View File

@ -10,6 +10,13 @@ data class Bewerb(
val klasse: String, val klasse: String,
val nennungen: Int, val nennungen: Int,
val warnungen: List<AbteilungsWarnung> = emptyList(), 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( data class AbteilungsWarnung(
@ -18,11 +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 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>
} }

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 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(),
) )
@ -70,6 +75,10 @@ sealed interface BewerbIntent {
data object StartNetworkScan : BewerbIntent data object StartNetworkScan : 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 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.StartNetworkScan -> startScan()
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.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. // Für dieses MVP zeigen wir einfach an, dass wir scannen.
} }
private fun generateStartliste() { fun generateStartliste() {
val selectedId = _state.value.selectedId ?: return val selectedId = _state.value.selectedId ?: return
reduce { it.copy(isLoading = true) } 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.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,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 { 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 {

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 package at.mocode.turnier.feature.di
import at.mocode.frontend.core.network.sync.SyncManager import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.turnier.feature.data.remote.DefaultAbteilungRepository import at.mocode.turnier.feature.data.remote.*
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.domain.AbteilungRepository import at.mocode.turnier.feature.domain.AbteilungRepository
import at.mocode.turnier.feature.domain.BewerbRepository import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.StartlistenRepository import at.mocode.turnier.feature.domain.StartlistenRepository
import at.mocode.turnier.feature.domain.TurnierRepository import at.mocode.turnier.feature.domain.TurnierRepository
import at.mocode.turnier.feature.presentation.AbteilungViewModel import at.mocode.turnier.feature.presentation.*
import at.mocode.turnier.feature.presentation.BewerbAnlegenViewModel
import at.mocode.turnier.feature.presentation.BewerbViewModel
import at.mocode.turnier.feature.presentation.TurnierViewModel
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
@ -22,6 +16,8 @@ val turnierFeatureModule = module {
single<BewerbRepository> { DefaultBewerbRepository(client = get(qualifier = named("apiClient"))) } single<BewerbRepository> { DefaultBewerbRepository(client = get(qualifier = named("apiClient"))) }
single<AbteilungRepository> { DefaultAbteilungRepository(client = get(qualifier = named("apiClient"))) } single<AbteilungRepository> { DefaultAbteilungRepository(client = get(qualifier = named("apiClient"))) }
single<StartlistenRepository> { DefaultStartlistenRepository(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 // ViewModels
factory { TurnierViewModel(repo = get()) } factory { TurnierViewModel(repo = get()) }
@ -40,4 +36,12 @@ val turnierFeatureModule = module {
factory { (bewerbId: Long, abteilungsNr: Int) -> factory { (bewerbId: Long, abteilungsNr: Int) ->
AbteilungViewModel(repo = get(), bewerbId = bewerbId, abteilungsNr = abteilungsNr) 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 package at.mocode.turnier.feature.presentation
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.koin.compose.koinInject
import org.koin.core.parameter.parametersOf
/** /**
* Detailansicht eines Turniers gemäß Vision_03. * Detailansicht eines Turniers gemäß Vision_03.
@ -56,10 +57,13 @@ fun TurnierDetailScreen(
"ARTIKEL", "ARTIKEL",
"ABRECHNUNG", "ABRECHNUNG",
"NENNUNGEN", "NENNUNGEN",
"ZEITPLAN",
"STARTLISTEN", "STARTLISTEN",
"ERGEBNISLISTEN", "ERGEBNISLISTEN",
) )
val bewerbViewModel: BewerbViewModel = koinInject { parametersOf(turnierId) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Horizontale Tab-Bar (direkt unter der TopBar) // Horizontale Tab-Bar (direkt unter der TopBar)
PrimaryScrollableTabRow( PrimaryScrollableTabRow(
@ -98,15 +102,23 @@ fun TurnierDetailScreen(
veranstalterBundesland = veranstalterBundesland, veranstalterBundesland = veranstalterBundesland,
veranstalterLogoUrl = veranstalterLogoUrl, veranstalterLogoUrl = veranstalterLogoUrl,
) )
1 -> OrganisationTabContent() 1 -> {
2 -> Box(modifier = Modifier.fillMaxSize()) { val nennungViewModel = koinInject<NennungViewModel>(parameters = { parametersOf(turnierId) })
Text("BEWERBE Tab (Anbindung in Arbeit)", modifier = Modifier.align(Alignment.Center)) OrganisationTabContent(viewModel = nennungViewModel)
} }
2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId)
3 -> ArtikelTabContent() 3 -> ArtikelTabContent()
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId) 4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
5 -> NennungenTabContent(onAbrechnungClick = { selectedTab = 4 }) 5 -> {
6 -> StartlistenTabContent() val nennungViewModel = koinInject<NennungViewModel>(parameters = { parametersOf(turnierId) })
7 -> ErgebnislistenTabContent() 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() // TurnierBewerbeTab.kt → BewerbeTabContent()
// TurnierNennungenTab.kt → NennungenTabContent() // TurnierNennungenTab.kt → NennungenTabContent()
// TurnierStartlistenTab.kt → StartlistenTabContent() // TurnierStartlistenTab.kt → StartlistenTabContent()
// TurnierZeitplanTab.kt → ZeitplanTabContent()
// TurnierErgebnislistenTab.kt → ErgebnislistenTabContent() // 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 ElBlue = Color(0xFF1E3A8A)
private val ElHeaderBg = Color(0xFFF1F5F9) private val ElHeaderBg = Color(0xFFF1F5F9)
@ -22,11 +24,19 @@ private val ElHeaderBg = Color(0xFFF1F5F9)
* - Rechts (280dp): Platzierung & Geldpreis-Panel * - Rechts (280dp): Platzierung & Geldpreis-Panel
*/ */
@Composable @Composable
fun ErgebnislistenTabContent() { fun ErgebnislistenTabContent(
viewModel: BewerbViewModel = koinInject()
) {
val state by viewModel.state.collectAsState()
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Bewerbs-Tabs + Tabelle ───────────────────────────── // ── Linke Spalte: Bewerbs-Tabs + Tabelle ─────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) { Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
ErgebnislistenBewerbsTabs() ErgebnislistenBewerbsTabs(
bewerbe = state.list,
selectedId = state.selectedId,
onSelect = { viewModel.send(BewerbIntent.Select(it)) }
)
} }
VerticalDivider() VerticalDivider()
@ -37,28 +47,31 @@ fun ErgebnislistenTabContent() {
} }
@Composable @Composable
private fun ErgebnislistenBewerbsTabs() { private fun ErgebnislistenBewerbsTabs(
val bewerbe = remember { bewerbe: List<Bewerb>,
listOf("Bewerb 1", "Bewerb 2", "Bewerb 3", "Bewerb 4", "Bewerb 5") selectedId: Long?,
} onSelect: (Long?) -> Unit
var selectedBewerb by remember { mutableIntStateOf(0) } ) {
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
PrimaryScrollableTabRow( PrimaryScrollableTabRow(
selectedTabIndex = selectedBewerb, selectedTabIndex = selectedIndex,
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
contentColor = ElBlue, contentColor = ElBlue,
edgePadding = 0.dp, edgePadding = 0.dp,
) { ) {
bewerbe.forEachIndexed { index, title -> bewerbe.forEachIndexed { index, bewerb ->
Tab( Tab(
selected = selectedBewerb == index, selected = selectedId == bewerb.id,
onClick = { selectedBewerb = index }, onClick = { onSelect(bewerb.id) },
text = { Text(title, fontSize = 12.sp) }, text = { Text(bewerb.tag, fontSize = 12.sp) },
) )
} }
} }
HorizontalDivider() HorizontalDivider()
val selectedBewerb = bewerbe.getOrNull(selectedIndex)
// Toolbar // Toolbar
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
@ -66,7 +79,7 @@ private fun ErgebnislistenBewerbsTabs() {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Bewerb ${selectedBewerb + 1} Ergebnisliste", text = selectedBewerb?.let { "${it.tag} ${it.name}" } ?: "Kein Bewerb ausgewählt",
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, fontSize = 13.sp,
) )

View File

@ -30,13 +30,18 @@ private val NennSelectedBg = Color(0xFFEFF6FF)
* - Rechts (360dp): Verkauf/Buchungen + Bewerbsübersicht * - Rechts (360dp): Verkauf/Buchungen + Bewerbsübersicht
*/ */
@Composable @Composable
fun NennungenTabContent(onAbrechnungClick: () -> Unit = {}) { fun NennungenTabContent(
viewModel: NennungViewModel,
onAbrechnungClick: () -> Unit = {}
) {
val state by viewModel.state.collectAsState()
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Suche + Tabelle ───────────────────────────────────── // ── Linke Spalte: Suche + Tabelle ─────────────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) { Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
NennungenSuchePanel() NennungenSuchePanel(viewModel, state)
HorizontalDivider() HorizontalDivider()
NennungenTabelle() NennungenTabelle(viewModel, state)
} }
VerticalDivider() VerticalDivider()
@ -56,40 +61,48 @@ fun NennungenTabContent(onAbrechnungClick: () -> Unit = {}) {
} }
@Composable @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)) { Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Pferd & Reiter suchen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) Text("Pferd & Reiter suchen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField( OutlinedTextField(
value = "", value = pferdQuery,
onValueChange = {}, onValueChange = {
pferdQuery = it
viewModel.searchPferde(it)
},
placeholder = { Text("Pferd suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) }, placeholder = { Text("Pferd suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) },
modifier = Modifier.weight(1f).height(44.dp), modifier = Modifier.weight(1f).height(44.dp),
singleLine = true, singleLine = true,
) )
OutlinedTextField( OutlinedTextField(
value = "", value = reiterQuery,
onValueChange = {}, onValueChange = {
reiterQuery = it
viewModel.searchReiter(it)
},
placeholder = { Text("Reiter suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) }, placeholder = { Text("Reiter suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) },
modifier = Modifier.weight(1f).height(44.dp), modifier = Modifier.weight(1f).height(44.dp),
singleLine = true, singleLine = true,
) )
Button( Button(
onClick = {}, onClick = { /* In einem echten Dialog würde hier die Auswahl kombiniert */ },
colors = ButtonDefaults.buttonColors(containerColor = NennBlue), colors = ButtonDefaults.buttonColors(containerColor = NennBlue),
modifier = Modifier.height(44.dp), modifier = Modifier.height(44.dp),
) { ) {
Text("Suchen", fontSize = 12.sp) Text("Nennen", fontSize = 12.sp)
} }
} }
} }
} }
@Composable @Composable
private fun NennungenTabelle() { private fun NennungenTabelle(viewModel: NennungViewModel, state: NennungenState) {
val nennungen = remember { sampleNennungen() }
var selectedIndex by remember { mutableIntStateOf(-1) } var selectedIndex by remember { mutableIntStateOf(-1) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@ -100,15 +113,18 @@ private fun NennungenTabelle() {
.background(NennHeaderBg) .background(NennHeaderBg)
.padding(horizontal = 12.dp, vertical = 6.dp), .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("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Reiter", 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)) Text("Status", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp))
} }
HorizontalDivider() HorizontalDivider()
if (nennungen.isEmpty()) { if (state.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (state.nennungen.isEmpty() && !state.isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine Nennungen vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280)) Text("Keine Nennungen vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
@ -122,7 +138,7 @@ private fun NennungenTabelle() {
} }
} else { } else {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(nennungen) { index, nennung -> itemsIndexed(state.nennungen) { index, nennung ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -132,15 +148,14 @@ private fun NennungenTabelle() {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
"${nennung.startnr}", nennung.id.takeLast(6),
fontSize = 12.sp, fontSize = 12.sp,
modifier = Modifier.width(60.dp), modifier = Modifier.width(60.dp),
color = NennBlue, color = NennBlue,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text(nennung.pferd, fontSize = 12.sp, modifier = Modifier.weight(1f)) Text(nennung.pferdId.takeLast(8), fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(nennung.reiter, fontSize = 12.sp, modifier = Modifier.weight(1f)) Text(nennung.reiterId.takeLast(8), fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(nennung.bewerb, fontSize = 12.sp, modifier = Modifier.weight(1f))
NennungStatusBadge(nennung.status) NennungStatusBadge(nennung.status)
} }
HorizontalDivider(color = Color(0xFFE5E7EB)) 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) * - Austragungsplätze: dynamische Liste (Sparte, Größe, Bezeichnung, Löschen)
*/ */
@Composable @Composable
fun OrganisationTabContent() { fun OrganisationTabContent(viewModel: NennungViewModel) {
val state by viewModel.state.collectAsState()
var turnierleiter by remember { mutableStateOf("") } var turnierleiter by remember { mutableStateOf("") }
var turnierbeauftragter by remember { mutableStateOf("") } var turnierbeauftragter by remember { mutableStateOf("") }
var technischerDelegierter by remember { mutableStateOf("") } var technischerDelegierter by remember { mutableStateOf("") }
@ -66,7 +68,10 @@ fun OrganisationTabContent() {
// ── Funktionäre & Offizielle ───────────────────────────────────────── // ── Funktionäre & Offizielle ─────────────────────────────────────────
OrgSectionCard(title = "Funktionäre & Offizielle (C-Satz)") { OrgSectionCard(title = "Funktionäre & Offizielle (C-Satz)") {
OrgSubSection("Turnier-Organisation") { 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("Turnierbeauftragter:", turnierbeauftragter) { turnierbeauftragter = it }
OrgSearchField("Technischer Delegierter:", technischerDelegierter) { technischerDelegierter = it } OrgSearchField("Technischer Delegierter:", technischerDelegierter) { technischerDelegierter = it }
OrgSearchField("Parcourschef:", parcourschef) { parcourschef = 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 SlBlue = Color(0xFF1E3A8A)
private val SlHeaderBg = Color(0xFFF1F5F9) private val SlHeaderBg = Color(0xFFF1F5F9)
@ -22,11 +24,22 @@ private val SlHeaderBg = Color(0xFFF1F5F9)
* - Rechts (280dp): Sortierung & Zeit-Panel * - Rechts (280dp): Sortierung & Zeit-Panel
*/ */
@Composable @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()) { Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Bewerbs-Tabs + Tabelle ───────────────────────────── // ── Linke Spalte: Bewerbs-Tabs + Tabelle ─────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) { 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() VerticalDivider()
@ -37,28 +50,33 @@ fun StartlistenTabContent() {
} }
@Composable @Composable
private fun StartlistenBewerbsTabs() { private fun StartlistenBewerbsTabs(
val bewerbe = remember { bewerbe: List<Bewerb>,
listOf("Bewerb 1", "Bewerb 2", "Bewerb 3", "Bewerb 4", "Bewerb 5") selectedId: Long?,
} onSelect: (Long?) -> Unit,
var selectedBewerb by remember { mutableIntStateOf(0) } currentStartliste: List<StartlistenZeile>,
onGenerate: () -> Unit
) {
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
PrimaryScrollableTabRow( PrimaryScrollableTabRow(
selectedTabIndex = selectedBewerb, selectedTabIndex = selectedIndex,
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
contentColor = SlBlue, contentColor = SlBlue,
edgePadding = 0.dp, edgePadding = 0.dp,
) { ) {
bewerbe.forEachIndexed { index, title -> bewerbe.forEachIndexed { index, bewerb ->
Tab( Tab(
selected = selectedBewerb == index, selected = selectedId == bewerb.id,
onClick = { selectedBewerb = index }, onClick = { onSelect(bewerb.id) },
text = { Text(title, fontSize = 12.sp) }, text = { Text(bewerb.tag, fontSize = 12.sp) },
) )
} }
} }
HorizontalDivider() HorizontalDivider()
val selectedBewerb = bewerbe.getOrNull(selectedIndex)
// Toolbar // Toolbar
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
@ -66,7 +84,7 @@ private fun StartlistenBewerbsTabs() {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Bewerb ${selectedBewerb + 1} Startliste", text = selectedBewerb?.let { "${it.tag} ${it.name}" } ?: "Kein Bewerb ausgewählt",
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, fontSize = 13.sp,
) )
@ -99,20 +117,40 @@ private fun StartlistenBewerbsTabs() {
} }
HorizontalDivider() HorizontalDivider()
// Leere Liste if (currentStartliste.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { // Leere Liste
Column(horizontalAlignment = Alignment.CenterHorizontally) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Starter vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280)) Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(8.dp)) Text("Keine Starter vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
Text("Startliste wird nach Nennungsschluss generiert.", fontSize = 12.sp, color = Color(0xFF9CA3AF)) Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(16.dp)) Text("Startliste wird nach Nennungsschluss generiert.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
Button( Spacer(Modifier.height(16.dp))
onClick = {}, Button(
colors = ButtonDefaults.buttonColors(containerColor = SlBlue), onClick = onGenerate,
) { colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
Text("Startliste generieren", fontSize = 13.sp) ) {
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, onVeranstaltungOeffnen: (Long) -> Unit,
onPingService: () -> Unit = {}, onPingService: () -> Unit = {},
onVereineOeffnen: () -> Unit = {}, onVereineOeffnen: () -> Unit = {},
onMeisterschaftenOeffnen: () -> Unit = {},
onCupsOeffnen: () -> Unit = {},
) { ) {
// Placeholder-Daten für die UI-Struktur (sichtbar als Cards) // Placeholder-Daten für die UI-Struktur (sichtbar als Cards)
val sample = listOf( val sample = listOf(
@ -68,7 +70,9 @@ fun AdminUebersichtScreen(
inVorbereitung = 0, inVorbereitung = 0,
gesamt = 0, gesamt = 0,
archiv = 0, archiv = 0,
onVereineClick = onVereineOeffnen onVereineClick = onVereineOeffnen,
onMeisterschaftenClick = onMeisterschaftenOeffnen,
onCupsClick = onCupsOeffnen
) )
// Toolbar // Toolbar
@ -159,6 +163,8 @@ private fun KpiKachelRow(
gesamt: Int, gesamt: Int,
archiv: Int, archiv: Int,
onVereineClick: () -> Unit = {}, onVereineClick: () -> Unit = {},
onMeisterschaftenClick: () -> Unit = {},
onCupsClick: () -> Unit = {},
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -178,18 +184,24 @@ private fun KpiKachelRow(
akzentFarbe = Color(0xFF3B82F6), akzentFarbe = Color(0xFF3B82F6),
modifier = Modifier.weight(1f), 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( KpiKachel(
label = "VEREINE", label = "VEREINE",
wert = "4", // Mock wert = "4", // Mock
akzentFarbe = Color(0xFF6B7280), akzentFarbe = Color(0xFF6B7280),
modifier = Modifier.weight(1f).clickable { onVereineClick() }, 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.frontend.features.verein.presentation.VereinViewModel
import at.mocode.ping.feature.presentation.PingScreen import at.mocode.ping.feature.presentation.PingScreen
import at.mocode.ping.feature.presentation.PingViewModel import at.mocode.ping.feature.presentation.PingViewModel
import at.mocode.turnier.feature.presentation.SeriesScreen
import at.mocode.turnier.feature.presentation.TurnierDetailScreen import at.mocode.turnier.feature.presentation.TurnierDetailScreen
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
@ -302,6 +303,24 @@ private fun DesktopTopBar(
fontWeight = FontWeight.SemiBold, 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 -> {} 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 -> { is AppScreen.Nennung -> {
val nennungViewModel: NennungViewModel = koinViewModel() val nennungViewModel: NennungViewModel = koinViewModel()
NennungsMaske( NennungsMaske(
@ -698,6 +725,8 @@ private fun DesktopContentArea(
else -> AdminUebersichtScreen( else -> AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) }, onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) }, 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.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import at.mocode.turnier.feature.domain.Bewerb import at.mocode.turnier.feature.domain.*
import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.StartlistenRepository
import at.mocode.turnier.feature.presentation.* import at.mocode.turnier.feature.presentation.*
import at.mocode.turnier.feature.data.remote.dto.NennungEinreichenRequest
import at.mocode.zns.parser.ZnsBewerb import at.mocode.zns.parser.ZnsBewerb
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterAuswahlScreen import at.mocode.frontend.features.veranstalter.presentation.VeranstalterAuswahlScreen
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailScreen import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailScreen
@ -108,8 +107,23 @@ fun PreviewTurnierStammdatenTab() {
@ComponentPreview @ComponentPreview
@Composable @Composable
fun PreviewTurnierOrganisationTab() { 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 { 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 create(model: Bewerb): Result<Bewerb> = Result.failure(NotImplementedError())
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 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) override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit)
} }
val mockStartlistenRepo = object : StartlistenRepository { val mockStartlistenRepo = object : StartlistenRepository {
@ -158,8 +175,23 @@ fun PreviewTurnierAbrechnungTab() {
@ComponentPreview @ComponentPreview
@Composable @Composable
fun PreviewTurnierNennungenTab() { 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 { MaterialTheme {
NennungenTabContent() NennungenTabContent(viewModel = vm)
} }
} }

View File

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