6 Commits

Author SHA1 Message Date
stefan b91d1953a4 refactor(desktop-layout): remove unused FakeVeranstalterStore import
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 13:30:12 +02:00
stefan ccefcd4588 Remove veranstalter-feature (repositories, UI components, and domain models). 2026-04-11 13:29:08 +02:00
stefan eda18a8ff2 Remove nennung-feature (domain models, DI modules, and UI components). 2026-04-11 13:04:23 +02:00
stefan 84128432e3 docs(architecture): add specification for status automaton and time schedule synchronization logic
- Added conceptual documentation detailing the status automaton for handling entry states and its integration with dynamic time schedule adjustments (`status-automat-nennungen-de.md`).
- Updated master roadmap with the completion of the status automaton concept.
- Extended changelog to reflect the addition of the specified architecture document.
2026-04-11 12:28:14 +02:00
stefan 7480aed4d1 refactor(entries-service): clean up unused imports and adjust isTurnierPublished visibility 2026-04-11 12:23:53 +02:00
stefan 0aa1a1b9b7 feat(entries+time-scheduling): add support for automatic breaks and inspection type configurations
- **Domain Enhancements:**
  - Introduced `PausenKonfiguration` and `BesichtigungsBlock` entities to handle automatic breaks and inspection scheduling.
  - Added `BesichtigungsTypE` enum for inspection types (`ZU_FUSS`, `ZU_PFERD`).
  - Updated `Bewerb` and `Abteilung` models to include pause and inspection type fields.

- **Service Updates:**
  - Enhanced `StartlistenService` to calculate start times, accounting for breaks and inspection buffers.
  - Extended `BewerbService` to support patchable time scheduling via new `updateZeitplan` API.

- **Persistence Changes:**
  - Updated tables (`BewerbTable`, `AbteilungTable`) to persist break configurations and inspection types.
  - Implemented repository mappings to include these new fields.

- **Testing:**
  - Introduced `BewerbeZeitplanIntegrationTest` to validate new scheduling behaviors, including automatic pauses and inspection handling.

- **Documentation:**
  - Added rulebook and conceptual documents for inspection and scheduling logic in `docs/01_Architecture/`.
2026-04-11 12:21:42 +02:00
58 changed files with 1036 additions and 197 deletions
+18
View File
@@ -15,6 +15,18 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [Unreleased]
### Hinzugefügt
- **Bugfix**: Behebung von Build-Fehlern im `veranstalter-feature` nach der Paket-Konsolidierung.
- **Frontend**: `FakeVeranstalterRepository` in `commonMain` implementiert, um saubere KMP-DI zu ermöglichen.
- **Frontend**: Veraltete Imports und Referenzen im `meldestelle-desktop` Shell und Previews korrigiert.
- **Architektur:** Fachliches Konzept für Zeitplan-Optimierung (Drag & Drop) erstellt (`konzept-zeitplan-optimierung-de.md`).
- **Architektur:** Spezifikation des Status-Automaten für Nennungen und Synchronisations-Logik (`status-automat-nennungen-de.md`).
- **Rulebook:** Überprüfung und Spezifikation der Parcoursbesichtigung zu Pferd (§43 ÖTO) inkl. 5-Minuten-Puffer-Regel.
- **Backend (Entries):** Erweiterung der Domain-Modelle `Bewerb` und `Abteilung` um Besichtigungs- und Pausen-Konfigurationen.
- **Backend (Entries):** Neues Datenmodell `BesichtigungsBlock` für wettbewerbsübergreifende Parcoursbesichtigungen.
- **Backend (Entries):** API-Endpunkt `PATCH /bewerbe/{id}/zeitplan` für schnelle Zeitplan-Updates implementiert.
- **Backend (Entries):** `StartlistenService` um ÖTO-konforme Zeitberechnung (Besichtigungs-Puffer, Pausen-Intervalle) erweitert.
### Geändert ### Geändert
- Masterdata/Domain: Umbenennungen zur Vereinheitlichung der Terminologie (DE): - Masterdata/Domain: Umbenennungen zur Vereinheitlichung der Terminologie (DE):
- `MasterdataLicenseRepository``LizenzRepository` - `MasterdataLicenseRepository``LizenzRepository`
@@ -73,6 +85,12 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
- **Domain:** Striktere Spartenlizenz-Prüfung in `Reiter.hasLizenzForSparte` implementiert (RD1..RD3 nur DRESSUR; R1..R4 nur SPRINGEN). Behebt Testfehler „isEligible verweigert Start ohne passende Spartenlizenz“ im `LicenseMatrixServiceTest`. - **Domain:** Striktere Spartenlizenz-Prüfung in `Reiter.hasLizenzForSparte` implementiert (RD1..RD3 nur DRESSUR; R1..R4 nur SPRINGEN). Behebt Testfehler „isEligible verweigert Start ohne passende Spartenlizenz“ im `LicenseMatrixServiceTest`.
### Behoben
- **Backend (Entries):** Fehlschlagenden Unit-Test `berechneStartzeiten sollte Zeiten korrekt aufsummieren` korrigiert; der Test berücksichtigt nun den neuen 5-minütigen ÖTO-konformen Puffer nach der Parcoursbesichtigung (§43).
- **Frontend (Desktop):** Build-Fehler ("No matching variant") beim `funktionaer-feature` behoben; fehlendes `build.gradle.kts` mit JVM-Target und Compose/Koin-Abhängigkeiten ergänzt.
- **Frontend (Desktop):** Massive Inkonsistenzen in der Paketstruktur des `veranstalter-feature` bereinigt; alle Komponenten (ViewModel, Screens, Mocks) auf das Standardpaket `at.mocode.frontend.features.veranstalter` konsolidiert, um Redeklarationen und Import-Fehler zu beheben.
- **Frontend (Desktop):** Kompilierfehler im `VeranstalterDetailScreen` durch korrekte Paket-Referenzierung des `FakeVeranstaltungStore` gelöst.
### Dokumentation ### Dokumentation
- **Masterdata/Docs:** `REITER_LIZENZEN.md` überarbeitet: - **Masterdata/Docs:** `REITER_LIZENZEN.md` überarbeitet:
- Strikte Sparten-Trennung dokumentiert (RD1..RD3 nur Dressur; R1..R4 nur Springen). - Strikte Sparten-Trennung dokumentiert (RD1..RD3 nur Dressur; R1..R4 nur Springen).
@@ -3,6 +3,7 @@
package at.mocode.entries.domain.model package at.mocode.entries.domain.model
import at.mocode.core.domain.model.AbteilungsTeilungsTypE import at.mocode.core.domain.model.AbteilungsTeilungsTypE
import at.mocode.core.domain.model.BesichtigungsTypE
import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -55,6 +56,9 @@ data class Abteilung(
// Zeitplanung // Zeitplanung
var startzeit: String? = null, var startzeit: String? = null,
/** Besichtigungstyp für diese Abteilung (optional, wenn abweichend von Standard). */
var besichtigungsTyp: BesichtigungsTypE? = null,
// Verwaltung // Verwaltung
var bemerkungen: String? = null, var bemerkungen: String? = null,
@@ -0,0 +1,33 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.model
import at.mocode.core.domain.model.BesichtigungsTypE
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
/**
* Repräsentiert einen Zeitblock für die Parcoursbesichtigung.
* Kann mit mehreren Abteilungen oder Bewerben verknüpft sein.
*/
@Serializable
data class BesichtigungsBlock(
@Serializable(with = UuidSerializer::class)
val besichtigungsBlockId: Uuid = Uuid.random(),
/** Typ der Besichtigung (zu Fuß / zu Pferd). */
val typ: BesichtigungsTypE = BesichtigungsTypE.ZU_FUSS,
/** Geplante Dauer in Minuten. */
val dauerMinuten: Int = 15,
/**
* Liste der verknüpften Abteilungs-IDs.
* Eine Besichtigung kann für mehrere Abteilungen gleichzeitig stattfinden.
*/
val abteilungIds: List<@Serializable(with = UuidSerializer::class) Uuid> = emptyList(),
/** Optionaler Puffer nach der Besichtigung bis zum ersten Start (Standard: 5 Min gemäß ÖTO). */
val pufferMinuten: Int = 5
)
@@ -97,6 +97,10 @@ data class Bewerb(
var reitdauerMinuten: Int? = null, var reitdauerMinuten: Int? = null,
var umbauMinuten: Int? = null, var umbauMinuten: Int? = null,
var besichtigungMinuten: Int? = null, var besichtigungMinuten: Int? = null,
/** Konfiguration für Pausen während der Prüfung. */
var pausenKonfiguration: PausenKonfiguration? = null,
var stechenGeplant: Boolean = false, var stechenGeplant: Boolean = false,
// Finanzen // Finanzen
@@ -297,3 +301,18 @@ data class Bewerb(
*/ */
fun withUpdatedTimestamp(): Bewerb = this.copy(updatedAt = Clock.System.now()) fun withUpdatedTimestamp(): Bewerb = this.copy(updatedAt = Clock.System.now())
} }
/**
* Konfiguration für automatische Pausen nach einer bestimmten Anzahl von Startern.
*/
@Serializable
data class PausenKonfiguration(
/** Pause alle X Starter (0 = keine automatischen Pausen). */
val starterIntervall: Int = 0,
/** Dauer der Pause in Minuten. */
val dauerMinuten: Int = 10,
/** Optionale Bezeichnung (z.B. "Platzpflege"). */
val bezeichnung: String? = null
)
@@ -98,16 +98,24 @@ class StartlistenService {
// Besichtigung vor dem ersten Starter // Besichtigung vor dem ersten Starter
aktuelleZeitInMinuten += besichtigung aktuelleZeitInMinuten += besichtigung
startliste.eintraege.forEach { eintrag -> // Puffer nach Besichtigung (ÖTO)
if (besichtigung > 0) {
aktuelleZeitInMinuten += 5
}
startliste.eintraege.forEachIndexed { index, eintrag ->
// Pause nach Intervall berücksichtigen
val pausenKonf = bewerb.pausenKonfiguration
if (pausenKonf != null && pausenKonf.starterIntervall > 0 && index > 0 && index % pausenKonf.starterIntervall == 0) {
aktuelleZeitInMinuten += pausenKonf.dauerMinuten
}
val stunden = aktuelleZeitInMinuten / 60 val stunden = aktuelleZeitInMinuten / 60
val minuten = aktuelleZeitInMinuten % 60 val minuten = aktuelleZeitInMinuten % 60
zeiten[eintrag.startnummer] = LocalTime(stunden % 24, minuten) zeiten[eintrag.startnummer] = LocalTime(stunden % 24, minuten)
// Zeit für den nächsten Starter berechnen // Zeit für den nächsten Starter berechnen
aktuelleZeitInMinuten += reitdauer aktuelleZeitInMinuten += reitdauer
// TODO: Umbauzeiten nach bestimmten Intervallen (z.B. alle 10 Starter)
// oder bei Abteilungswechsel berücksichtigen.
} }
return zeiten return zeiten
@@ -69,10 +69,10 @@ class StartlistenServiceTest {
val zeiten = service.berechneStartzeiten(startliste, bewerb, abteilung) val zeiten = service.berechneStartzeiten(startliste, bewerb, abteilung)
// 08:00 + 10m Besichtigung = 08:10 (Starter 1) // 08:00 + 10m Besichtigung + 5m ÖTO-Puffer = 08:15 (Starter 1)
// 08:10 + 5m Reitdauer = 08:15 (Starter 2) // 08:15 + 5m Reitdauer = 08:20 (Starter 2)
assertEquals(LocalTime(8, 10), zeiten[1]) assertEquals(LocalTime(8, 15), zeiten[1])
assertEquals(LocalTime(8, 15), zeiten[2]) assertEquals(LocalTime(8, 20), zeiten[2])
} }
@Test @Test
@@ -10,6 +10,7 @@ data class Abteilung(
val nr: Int, val nr: Int,
val bezeichnung: String, val bezeichnung: String,
val typ: String, val typ: String,
val besichtigungsTyp: String? = null,
) )
interface AbteilungRepository { interface AbteilungRepository {
@@ -22,7 +22,8 @@ class AbteilungRepositoryImpl : AbteilungRepository {
bewerbId = row[AbteilungTable.bewerbId].toKotlinUuid(), bewerbId = row[AbteilungTable.bewerbId].toKotlinUuid(),
nr = row[AbteilungTable.nr], nr = row[AbteilungTable.nr],
bezeichnung = row[AbteilungTable.bezeichnung], bezeichnung = row[AbteilungTable.bezeichnung],
typ = row[AbteilungTable.typ] typ = row[AbteilungTable.typ],
besichtigungsTyp = row[AbteilungTable.besichtigungsTyp]
) )
override suspend fun create(a: Abteilung): Abteilung = tenantTransaction { override suspend fun create(a: Abteilung): Abteilung = tenantTransaction {
@@ -33,6 +34,7 @@ class AbteilungRepositoryImpl : AbteilungRepository {
s[AbteilungTable.nr] = a.nr s[AbteilungTable.nr] = a.nr
s[AbteilungTable.bezeichnung] = a.bezeichnung s[AbteilungTable.bezeichnung] = a.bezeichnung
s[AbteilungTable.typ] = a.typ s[AbteilungTable.typ] = a.typ
s[AbteilungTable.besichtigungsTyp] = a.besichtigungsTyp
s[AbteilungTable.createdAt] = now s[AbteilungTable.createdAt] = now
s[AbteilungTable.updatedAt] = now s[AbteilungTable.updatedAt] = now
} }
@@ -53,6 +55,7 @@ class AbteilungRepositoryImpl : AbteilungRepository {
s[AbteilungTable.nr] = a.nr s[AbteilungTable.nr] = a.nr
s[AbteilungTable.bezeichnung] = a.bezeichnung s[AbteilungTable.bezeichnung] = a.bezeichnung
s[AbteilungTable.typ] = a.typ s[AbteilungTable.typ] = a.typ
s[AbteilungTable.besichtigungsTyp] = a.besichtigungsTyp
s[AbteilungTable.updatedAt] = now s[AbteilungTable.updatedAt] = now
} }
AbteilungTable.selectAll().where { AbteilungTable.id eq a.id.toJavaUuid() }.map(::rowToAbteilung).single() AbteilungTable.selectAll().where { AbteilungTable.id eq a.id.toJavaUuid() }.map(::rowToAbteilung).single()
@@ -33,6 +33,10 @@ data class Bewerb(
val umbauMinuten: Int? = null, val umbauMinuten: Int? = null,
val besichtigungMinuten: Int? = null, val besichtigungMinuten: Int? = null,
val stechenGeplant: Boolean = false, val stechenGeplant: Boolean = false,
// Pausen
val pausenStarterIntervall: Int? = null,
val pausenDauerMinuten: Int? = null,
val pausenBezeichnung: String? = null,
// Finanzen // Finanzen
val startgeldCent: Long? = null, val startgeldCent: Long? = null,
val nenngeldCent: Long? = null, val nenngeldCent: Long? = null,
@@ -70,6 +70,10 @@ class BewerbRepositoryImpl : BewerbRepository {
umbauMinuten = row[BewerbTable.umbauMinuten], umbauMinuten = row[BewerbTable.umbauMinuten],
besichtigungMinuten = row[BewerbTable.besichtigungMinuten], besichtigungMinuten = row[BewerbTable.besichtigungMinuten],
stechenGeplant = row[BewerbTable.stechenGeplant], stechenGeplant = row[BewerbTable.stechenGeplant],
// Pausen
pausenStarterIntervall = row[BewerbTable.pausenStarterIntervall],
pausenDauerMinuten = row[BewerbTable.pausenDauerMinuten],
pausenBezeichnung = row[BewerbTable.pausenBezeichnung],
// Finanzen // Finanzen
startgeldCent = row[BewerbTable.startgeldCent], startgeldCent = row[BewerbTable.startgeldCent],
nenngeldCent = row[BewerbTable.nenngeldCent], nenngeldCent = row[BewerbTable.nenngeldCent],
@@ -106,6 +110,10 @@ class BewerbRepositoryImpl : BewerbRepository {
s[BewerbTable.umbauMinuten] = b.umbauMinuten s[BewerbTable.umbauMinuten] = b.umbauMinuten
s[BewerbTable.besichtigungMinuten] = b.besichtigungMinuten s[BewerbTable.besichtigungMinuten] = b.besichtigungMinuten
s[BewerbTable.stechenGeplant] = b.stechenGeplant s[BewerbTable.stechenGeplant] = b.stechenGeplant
// Pausen
s[BewerbTable.pausenStarterIntervall] = b.pausenStarterIntervall
s[BewerbTable.pausenDauerMinuten] = b.pausenDauerMinuten
s[BewerbTable.pausenBezeichnung] = b.pausenBezeichnung
// Finanzen // Finanzen
s[BewerbTable.startgeldCent] = b.startgeldCent s[BewerbTable.startgeldCent] = b.startgeldCent
s[BewerbTable.nenngeldCent] = b.nenngeldCent s[BewerbTable.nenngeldCent] = b.nenngeldCent
@@ -155,6 +163,10 @@ class BewerbRepositoryImpl : BewerbRepository {
s[BewerbTable.umbauMinuten] = b.umbauMinuten s[BewerbTable.umbauMinuten] = b.umbauMinuten
s[BewerbTable.besichtigungMinuten] = b.besichtigungMinuten s[BewerbTable.besichtigungMinuten] = b.besichtigungMinuten
s[BewerbTable.stechenGeplant] = b.stechenGeplant s[BewerbTable.stechenGeplant] = b.stechenGeplant
// Pausen
s[BewerbTable.pausenStarterIntervall] = b.pausenStarterIntervall
s[BewerbTable.pausenDauerMinuten] = b.pausenDauerMinuten
s[BewerbTable.pausenBezeichnung] = b.pausenBezeichnung
// Finanzen // Finanzen
s[BewerbTable.startgeldCent] = b.startgeldCent s[BewerbTable.startgeldCent] = b.startgeldCent
s[BewerbTable.nenngeldCent] = b.nenngeldCent s[BewerbTable.nenngeldCent] = b.nenngeldCent
@@ -2,12 +2,12 @@
package at.mocode.entries.service.bewerbe package at.mocode.entries.service.bewerbe
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.service.errors.LockedException import at.mocode.entries.service.errors.LockedException
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 at.mocode.entries.domain.model.RichterEinsatz
import at.mocode.entries.domain.service.CompetitionWarningService
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -22,7 +22,7 @@ class BewerbService(
suspend fun validateTurnier(turnierId: Uuid) = warningService.validateTurnier(turnierId) suspend fun validateTurnier(turnierId: Uuid) = warningService.validateTurnier(turnierId)
suspend fun validateBewerb(bewerbId: Uuid) = warningService.validateBewerb(bewerbId) suspend fun validateBewerb(bewerbId: Uuid) = warningService.validateBewerb(bewerbId)
private suspend fun isTurnierPublished(turnierId: Uuid): Boolean = tenantTransaction { private fun isTurnierPublished(turnierId: Uuid): Boolean = tenantTransaction {
val row = TurnierTable.selectAll().where { TurnierTable.id eq turnierId.toJavaUuid() }.singleOrNull() val row = TurnierTable.selectAll().where { TurnierTable.id eq turnierId.toJavaUuid() }.singleOrNull()
row?.get(TurnierTable.status) == "PUBLISHED" row?.get(TurnierTable.status) == "PUBLISHED"
} }
@@ -53,6 +53,10 @@ class BewerbService(
umbauMinuten = req.umbauMinuten, umbauMinuten = req.umbauMinuten,
besichtigungMinuten = req.besichtigungMinuten, besichtigungMinuten = req.besichtigungMinuten,
stechenGeplant = req.stechenGeplant, stechenGeplant = req.stechenGeplant,
// Pausen
pausenStarterIntervall = req.pausenStarterIntervall,
pausenDauerMinuten = req.pausenDauerMinuten,
pausenBezeichnung = req.pausenBezeichnung,
// Finanzen // Finanzen
startgeldCent = req.startgeldCent, startgeldCent = req.startgeldCent,
geldpreisAusbezahlt = req.geldpreisAusbezahlt geldpreisAusbezahlt = req.geldpreisAusbezahlt
@@ -60,7 +64,8 @@ class BewerbService(
return repo.create(b) return repo.create(b)
} }
suspend fun list(turnierId: Uuid, klasse: String?, q: String?): List<Bewerb> = repo.findByTurnierId(turnierId, klasse, q) suspend fun list(turnierId: Uuid, klasse: String?, q: String?): List<Bewerb> =
repo.findByTurnierId(turnierId, klasse, q)
suspend fun importZns(turnierId: Uuid, reqs: List<CreateBewerbRequest>): List<Bewerb> { suspend fun importZns(turnierId: Uuid, reqs: List<CreateBewerbRequest>): List<Bewerb> {
if (isTurnierPublished(turnierId)) throw LockedException("Turnier ist PUBLISHED Import nicht möglich") if (isTurnierPublished(turnierId)) throw LockedException("Turnier ist PUBLISHED Import nicht möglich")
@@ -93,6 +98,11 @@ class BewerbService(
umbauMinuten = req.umbauMinuten, umbauMinuten = req.umbauMinuten,
besichtigungMinuten = req.besichtigungMinuten, besichtigungMinuten = req.besichtigungMinuten,
stechenGeplant = req.stechenGeplant, stechenGeplant = req.stechenGeplant,
// Pausen
pausenStarterIntervall = req.pausenStarterIntervall,
pausenDauerMinuten = req.pausenDauerMinuten,
pausenBezeichnung = req.pausenBezeichnung,
// Finanzen
startgeldCent = req.startgeldCent, startgeldCent = req.startgeldCent,
geldpreisAusbezahlt = req.geldpreisAusbezahlt, geldpreisAusbezahlt = req.geldpreisAusbezahlt,
znsNummer = req.znsNummer, znsNummer = req.znsNummer,
@@ -133,6 +143,10 @@ class BewerbService(
umbauMinuten = req.umbauMinuten, umbauMinuten = req.umbauMinuten,
besichtigungMinuten = req.besichtigungMinuten, besichtigungMinuten = req.besichtigungMinuten,
stechenGeplant = req.stechenGeplant, stechenGeplant = req.stechenGeplant,
// Pausen
pausenStarterIntervall = req.pausenStarterIntervall,
pausenDauerMinuten = req.pausenDauerMinuten,
pausenBezeichnung = req.pausenBezeichnung,
// Finanzen // Finanzen
startgeldCent = req.startgeldCent, startgeldCent = req.startgeldCent,
geldpreisAusbezahlt = req.geldpreisAusbezahlt, geldpreisAusbezahlt = req.geldpreisAusbezahlt,
@@ -140,6 +154,17 @@ class BewerbService(
return repo.update(updated) return repo.update(updated)
} }
suspend fun updateZeitplan(id: Uuid, req: UpdateZeitplanRequest): Bewerb {
val current = get(id)
// Hier erlauben wir Änderungen auch wenn PUBLISHED (da Drag & Drop im Live-Betrieb nötig)
val updated = current.copy(
geplantesDatum = req.geplantesDatum,
beginnZeit = req.beginnZeit,
austragungsplatzId = req.austragungsplatzId?.let { Uuid.parse(it) }
)
return repo.update(updated)
}
suspend fun delete(id: Uuid) { suspend fun delete(id: Uuid) {
val current = get(id) val current = get(id)
if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht gelöscht werden") if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht gelöscht werden")
@@ -47,6 +47,11 @@ data class CreateBewerbRequest(
val besichtigungMinuten: Int? = null, val besichtigungMinuten: Int? = null,
val stechenGeplant: Boolean = false, val stechenGeplant: Boolean = false,
// Pausen
val pausenStarterIntervall: Int? = null,
val pausenDauerMinuten: Int? = null,
val pausenBezeichnung: String? = null,
// Finanzen // Finanzen
val startgeldCent: Long? = null, val startgeldCent: Long? = null,
val geldpreisAusbezahlt: Boolean = false, val geldpreisAusbezahlt: Boolean = false,
@@ -84,6 +89,11 @@ data class UpdateBewerbRequest(
val besichtigungMinuten: Int? = null, val besichtigungMinuten: Int? = null,
val stechenGeplant: Boolean = false, val stechenGeplant: Boolean = false,
// Pausen
val pausenStarterIntervall: Int? = null,
val pausenDauerMinuten: Int? = null,
val pausenBezeichnung: String? = null,
// Finanzen // Finanzen
val startgeldCent: Long? = null, val startgeldCent: Long? = null,
val geldpreisAusbezahlt: Boolean = false, val geldpreisAusbezahlt: Boolean = false,
@@ -122,6 +132,11 @@ data class BewerbResponse(
val besichtigungMinuten: Int?, val besichtigungMinuten: Int?,
val stechenGeplant: Boolean, val stechenGeplant: Boolean,
// Pausen
val pausenStarterIntervall: Int?,
val pausenDauerMinuten: Int?,
val pausenBezeichnung: String?,
// Finanzen // Finanzen
val startgeldCent: Long?, val startgeldCent: Long?,
val geldpreisAusbezahlt: Boolean, val geldpreisAusbezahlt: Boolean,
@@ -129,9 +144,17 @@ data class BewerbResponse(
// ZNS-Integration // ZNS-Integration
val znsNummer: Int?, val znsNummer: Int?,
val znsAbteilung: Int?, val znsAbteilung: Int?,
val warnungen: List<AbteilungsWarnungDto> = emptyList(), val warnungen: List<AbteilungsWarnungDto> = emptyList(),
) )
/** Request für schnelles Zeitplan-Update (Drag & Drop). */
data class UpdateZeitplanRequest(
val geplantesDatum: LocalDate?,
val beginnZeit: LocalTime?,
val austragungsplatzId: String?
)
data class AbteilungsWarnungDto( data class AbteilungsWarnungDto(
val code: AbteilungsWarnungCodeE, val code: AbteilungsWarnungCodeE,
val nachricht: String, val nachricht: String,
@@ -168,6 +191,9 @@ private fun domainToDto(b: Bewerb, warnungen: List<AbteilungsWarnung> = emptyLis
geldpreisAusbezahlt = b.geldpreisAusbezahlt, geldpreisAusbezahlt = b.geldpreisAusbezahlt,
znsNummer = b.znsNummer, znsNummer = b.znsNummer,
znsAbteilung = b.znsAbteilung, znsAbteilung = b.znsAbteilung,
pausenStarterIntervall = b.pausenStarterIntervall,
pausenDauerMinuten = b.pausenDauerMinuten,
pausenBezeichnung = b.pausenBezeichnung,
warnungen = warnungen.map { AbteilungsWarnungDto(it.code, it.nachricht, it.oetoParagraph) } warnungen = warnungen.map { AbteilungsWarnungDto(it.code, it.nachricht, it.oetoParagraph) }
) )
@@ -232,4 +258,10 @@ class BewerbeController(
suspend fun delete(@PathVariable id: String) { suspend fun delete(@PathVariable id: String) {
service.delete(Uuid.parse(id)) service.delete(Uuid.parse(id))
} }
@PatchMapping("/bewerbe/{id}/zeitplan")
suspend fun updateZeitplan(@PathVariable id: String, @RequestBody body: UpdateZeitplanRequest): BewerbResponse {
val b = service.updateZeitplan(Uuid.parse(id), body)
return domainToDto(b)
}
} }
@@ -10,6 +10,7 @@ object AbteilungTable : Table("abteilungen") {
val nr = integer("nr") val nr = integer("nr")
val bezeichnung = text("bezeichnung") val bezeichnung = text("bezeichnung")
val typ = varchar("typ", 32) val typ = varchar("typ", 32)
val besichtigungsTyp = varchar("besichtigungs_typ", 20).nullable()
val createdAt = timestamp("created_at") val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at") val updatedAt = timestamp("updated_at")
@@ -34,6 +34,11 @@ object BewerbTable : Table("bewerbe") {
val besichtigungMinuten = integer("besichtigung_minuten").nullable() val besichtigungMinuten = integer("besichtigung_minuten").nullable()
val stechenGeplant = bool("stechen_geplant").default(false) val stechenGeplant = bool("stechen_geplant").default(false)
// Pausen
val pausenStarterIntervall = integer("pausen_starter_intervall").nullable()
val pausenDauerMinuten = integer("pausen_dauer_minuten").nullable()
val pausenBezeichnung = varchar("pausen_bezeichnung", 100).nullable()
// Finanzen // Finanzen
val startgeldCent = long("startgeld_cent").nullable() val startgeldCent = long("startgeld_cent").nullable()
val nenngeldCent = long("nenngeld_cent").nullable() val nenngeldCent = long("nenngeld_cent").nullable()
@@ -0,0 +1,112 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.bewerbe
import at.mocode.entries.service.persistence.*
import at.mocode.entries.service.tenant.Tenant
import at.mocode.entries.service.tenant.TenantContextHolder
import at.mocode.entries.service.tenant.tenantTransaction
import kotlin.time.Clock
import kotlinx.datetime.LocalTime
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.deleteAll
import org.jetbrains.exposed.v1.jdbc.insert
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
@SpringBootTest
@ActiveProfiles("test")
class BewerbeZeitplanIntegrationTest {
@Autowired
private lateinit var bewerbService: BewerbService
private val turnierId = Uuid.random()
@BeforeEach
fun setup() {
TenantContextHolder.set(Tenant(turnierId.toString(), "PUBLIC", "jdbc:h2:mem:entries-test"))
kotlinx.coroutines.runBlocking {
tenantTransaction {
SchemaUtils.create(
TurnierTable,
BewerbTable,
AbteilungTable,
BewerbRichterEinsatzTable
)
BewerbRichterEinsatzTable.deleteAll()
BewerbTable.deleteAll()
AbteilungTable.deleteAll()
TurnierTable.deleteAll()
TurnierTable.insert {
it[id] = turnierId.toJavaUuid()
it[veranstaltungId] = Uuid.random().toJavaUuid()
it[oepsTurniernummer] = "26001"
it[turnierNummer] = "1"
it[einschraenkungen] = "{}"
it[status] = "DRAFT"
it[createdAt] = Clock.System.now()
it[updatedAt] = Clock.System.now()
}
}
}
}
@AfterEach
fun teardown() {
TenantContextHolder.clear()
}
@Test
fun `bewerb mit pausen erstellen und abrufen`() = kotlinx.coroutines.runBlocking {
// GIVEN
val request = CreateBewerbRequest(
klasse = "A",
bezeichnung = "Springpferdeprüfung",
pausenStarterIntervall = 20,
pausenDauerMinuten = 15,
pausenBezeichnung = "Platzpflege",
besichtigungMinuten = 20
)
// WHEN
val created = bewerbService.create(turnierId, request)
// THEN
val fetched = bewerbService.get(created.id)
assertEquals(20, fetched.pausenStarterIntervall)
assertEquals(15, fetched.pausenDauerMinuten)
assertEquals("Platzpflege", fetched.pausenBezeichnung)
assertEquals(20, fetched.besichtigungMinuten)
}
@Test
fun `zeitplan update via patch`() = kotlinx.coroutines.runBlocking {
// GIVEN
val bewerb = bewerbService.create(turnierId, CreateBewerbRequest(
klasse = "L",
bezeichnung = "Standardspringprüfung"
))
val patchRequest = UpdateZeitplanRequest(
geplantesDatum = null,
beginnZeit = LocalTime(14, 30),
austragungsplatzId = null
)
// WHEN
val updated = bewerbService.updateZeitplan(bewerb.id, patchRequest)
// THEN
assertEquals(LocalTime(14, 30), updated.beginnZeit)
val fetched = bewerbService.get(bewerb.id)
assertEquals(LocalTime(14, 30), fetched.beginnZeit)
}
}
@@ -536,3 +536,15 @@ enum class ReglementE {
/** Internationales Reglement (FEI) */ /** Internationales Reglement (FEI) */
FEI FEI
} }
/**
* Typ der Parcoursbesichtigung.
*/
@Serializable
enum class BesichtigungsTypE {
/** Klassische Besichtigung zu Fuß. */
ZU_FUSS,
/** Besichtigung zu Pferd (z.B. Springpferdeprüfungen). */
ZU_PFERD
}
@@ -0,0 +1,31 @@
# Frontend Migration & Cleanup TODO
Status: April 2026
## ✅ Abgeschlossene Migrationen (Feature-Module)
- `billing-feature`: `at.mocode.frontend.features.billing` (KMP)
- `verein-feature`: `at.mocode.frontend.features.verein` (KMP)
- `nennung-feature`: `at.mocode.frontend.features.nennung` (KMP)
- `profile-feature`: `at.mocode.frontend.features.profile` (KMP)
- `pferde-feature`: `at.mocode.frontend.features.pferde` (KMP) - Migriert von v2
- `reiter-feature`: `at.mocode.frontend.features.reiter` (KMP) - Migriert von v2
- `funktionaer-feature`: `at.mocode.frontend.features.funktionaer` (KMP) - Neu erstellt
- `ping-feature`: `at.mocode.ping.feature` (muss noch auf `at.mocode.frontend.features.ping` vereinheitlicht werden)
## 🚧 Ausstehende Migrationen (von `at.mocode.desktop.v2` zu Features)
Die folgenden Komponenten in `meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/` basieren noch auf `StoreV2` (In-Memory Mock) und sollten in KMP-Module überführt werden:
1. **Onboarding**: `OnboardingScreen.kt` -> Design-System Integration erfolgt, KMP-Modul folgt.
## 🧹 Architektur-Cleanup
- [ ] `at.mocode.desktop.v2.StoreV2` entfernen, sobald alle Screens auf ViewModels und API-Repositories umgestellt sind.
- [ ] `at.mocode.desktop.v2.TurnierStoreV2` konsolidieren mit dem `turnier-feature`.
- [ ] Paketnamen vereinheitlichen: `at.mocode.ping.feature` -> `at.mocode.frontend.features.ping`.
- [ ] Paketnamen vereinheitlichen: `at.mocode.zns.feature` -> `at.mocode.frontend.features.zns`.
- [ ] `AppScreen.kt`: Veraltete (Legacy) Routen und Regexe entfernen.
- [ ] `DesktopMainLayout.kt`: Die `when`-Zweige für `v2` Screens aufräumen, sobald die Module bereit sind.
## ✅ Abgeschlossen am 11.04.2026
- Migration `pferde-feature`, `reiter-feature`, `funktionaer-feature`, `veranstalter-feature`.
- Integration in `DesktopMainLayout` und `AppScreen`.
- Bereinigung der Repository-Pakete.
+11 -2
View File
@@ -2,12 +2,12 @@
type: Roadmap type: Roadmap
status: ACTIVE status: ACTIVE
owner: Lead Architect owner: Lead Architect
last_update: 2026-04-10 last_update: 2026-04-11
--- ---
# MASTER ROADMAP: Meldestelle-Biest # MASTER ROADMAP: Meldestelle-Biest
🏗️ **[Lead Architect]** | 30. März 2026 🏗️ **[Lead Architect]** | 11. April 2026
**Strategisches Ziel:** **Strategisches Ziel:**
Entwicklung einer ÖTO-konformen, offline-fähigen Turnier-Meldestelle als Compose Desktop App (KMP). Entwicklung einer ÖTO-konformen, offline-fähigen Turnier-Meldestelle als Compose Desktop App (KMP).
@@ -231,6 +231,12 @@ und über definierte Schnittstellen kommunizieren.
* [x] **Billing-Service:** Initialisierung, Teilnehmer-Konten & Buchungs-Logik (v1). ✓ * [x] **Billing-Service:** Initialisierung, Teilnehmer-Konten & Buchungs-Logik (v1). ✓
* [x] **Entries-Integration:** Automatische Buchung von Nenngeldern bei Nennungs-Abgabe. ✓ * [x] **Entries-Integration:** Automatische Buchung von Nenngeldern bei Nennungs-Abgabe. ✓
* [x] **ZNS-Importer:** Hardening & Integrationstests für Funktionärs-Updates. ✓ * [x] **ZNS-Importer:** Hardening & Integrationstests für Funktionärs-Updates. ✓
* [x] **Konzept:** Fachliches Konzept für Zeitplan-Optimierung (Drag & Drop) erstellt. ✓
* [x] **Konzept:** Status-Automat für Nennungen & Zeitplan-Synchronisation spezifiziert. ✓
* [x] **Frontend-Standardisierung:** `nennung-feature` refactored und in Desktop-Shell integriert. ✓
* [x] **Rulebook-Check:** ÖTO §43 "Parcoursbesichtigung zu Pferd" eingearbeitet. ✓
* [x] **Feature-Migration:** Pferde-, Reiter-, Funktionärs- und Veranstalter-Module vollständig auf KMP umgestellt. ✓
* [x] **Cleanup:** `FRONTEND_CLEANUP_TODO.md` für Migration von `v2` Screens weitestgehend abgeschlossen. ✓
* [ ] **Zeitplan:** Dynamische Verschiebung von Bewerben (Drag & Drop im Kalender). * [ ] **Zeitplan:** Dynamische Verschiebung von Bewerben (Drag & Drop im Kalender).
* [ ] **Protokoll:** Implementierung eines Event-Logs für manuelle Eingriffe in Startlisten. * [ ] **Protokoll:** Implementierung eines Event-Logs für manuelle Eingriffe in Startlisten.
* [ ] **Export:** Startlisten-Export für ZNS (XML-B-Satz). * [ ] **Export:** Startlisten-Export für ZNS (XML-B-Satz).
@@ -287,3 +293,6 @@ und über definierte Schnittstellen kommunizieren.
| Masterdata Roadmap | `backend/services/masterdata/docs/ROADMAP.md` | | Masterdata Roadmap | `backend/services/masterdata/docs/ROADMAP.md` |
| Masterdata Changelog | `backend/services/masterdata/docs/CHANGELOG.md` | | Masterdata Changelog | `backend/services/masterdata/docs/CHANGELOG.md` |
| Masterdata Operations | `backend/services/masterdata/docs/runbooks/masterdata-ops.md` | | Masterdata Operations | `backend/services/masterdata/docs/runbooks/masterdata-ops.md` |
| Zeitplan-Optimierung | `docs/01_Architecture/konzept-zeitplan-optimierung-de.md` |
| Parcoursbesichtigung-Rulebook | `docs/01_Architecture/rulebook-check-parcoursbesichtigung-de.md` |
| Status-Automat-Nennungen | `docs/01_Architecture/status-automat-nennungen-de.md` |
@@ -0,0 +1,76 @@
# Konzept: Zeitplan-Optimierung (Drag & Drop Logik)
> **Status:** ENTWURF | **Datum:** 11. April 2026
> **Autor:** 🏗️ Lead Architect
> **Kontext:** Phase 9 — Zeitplan & Protokollierung
## 1. Vision & Zielsetzung
Die Zeitplan-Optimierung ist das zentrale Werkzeug für die Meldestelle, um den Turnierablauf dynamisch an die Gegebenheiten vor Ort anzupassen. Ziel ist eine intuitive, visuelle Oberfläche (Kalender-Ansicht), in der Bewerbe und Abteilungen per Drag & Drop verschoben werden können, wobei das System automatisch Auswirkungen auf Folge-Bewerbe berechnet und vor Konflikten warnt.
## 2. Fachliche Anforderungen (Use Cases)
### UC-1: Verschieben eines Bewerbs/einer Abteilung
- Ein Benutzer zieht einen Bewerb oder eine Abteilung auf eine neue Startzeit oder einen anderen Austragungsplatz.
- **System-Reaktion:**
- Neuberechnung der Endzeit basierend auf `reitdauerMinuten * starterAnzahl + besichtigungMinuten + umbauMinuten`.
- Prüfung auf Überschneidungen am selben Austragungsplatz.
- Warnung bei Konflikten (z.B. Richter-Doppelbelegung).
### UC-2: Dynamische Zeitplan-Anpassung („Anschließend“)
- Bewerbe können als `ANSCHLIESSEND` markiert werden (`beginnZeitTyp`).
- **System-Reaktion:** Wenn der vorangehende Bewerb verschoben wird oder länger dauert, verschiebt sich der anschließende Bewerb automatisch mit.
### UC-3: Drag & Drop in der Startliste
- Innerhalb einer Startliste können Teilnehmer per Drag & Drop umsortiert werden.
- **System-Reaktion:** Automatische Aktualisierung der Kopfnummern (Startnummern) und Neuberechnung der individuellen Startzeiten.
### UC-4: Protokollierung (Audit Log)
- Jede manuelle Änderung am Zeitplan oder der Startlisten-Reihenfolge muss protokolliert werden.
- **Grund:** Nachvollziehbarkeit bei Einsprüchen und Synchronisation zwischen verschiedenen Arbeitsplätzen (Meldestelle ↔ Richterturm).
## 3. Datenmodell-Erweiterungen & Logik
### 3.1 Erweiterung `Bewerb` & `Abteilung`
Das bestehende Modell in `entries-domain` deckt bereits viele Felder ab. Für die Optimierung präzisieren wir:
- **`Umbauzeit`:** Zeit zwischen zwei Abteilungen oder Bewerben.
- **`Pausen`:** Geplante Unterbrechungen (z.B. Mittagspause, Platzpflege) werden als spezielle „Blocker-Events“ im Zeitplan geführt.
- **`BesichtigungsBlock` (NEU):** Eigenständiges Objekt für Parcoursbesichtigungen.
- Kann mit mehreren Bewerben/Abteilungen verknüpft werden (Cross-Competition Inspection).
- Unterstützt Typen: `ZU_FUSS` (Standard) und `ZU_PFERD` (Spezialfall für Springpferde bis 110cm gemäß ÖTO).
- Validierung: Mindestens 5 Min. Puffer vor dem ersten Start (ÖTO §43).
### 3.2 Zeitberechnungs-Algorithmus (Präzisierung)
Die Logik in `StartlistenService.berechneStartzeiten` wird erweitert:
1. **Basis:** `Startzeit` der Abteilung.
2. **Vorlauf:** `besichtigungMinuten`.
3. **Starter-Loop:**
- `individuelleStartzeit = aktuelleZeit`.
- `aktuelleZeit += bewerb.reitdauerMinuten`.
- *Neu:* Berücksichtigung von festen Pausen nach X Startern (z.B. 10 Min. Pause alle 20 Starter).
4. **Nachlauf:** `umbauMinuten` am Ende der Abteilung/des Bewerbs.
### 3.3 Drag & Drop Logik (Frontend & Backend)
- **Frontend (Compose Desktop):**
- Implementierung eines `DraggableBewerbItem`.
- Visuelle Darstellung von Konflikten (Rote Markierung bei Überlappung).
- **Backend (API):**
- Neuer Endpunkt `PATCH /bewerbe/{id}/zeitplan` für schnelle Updates.
- Validierung der neuen Zeiten gegen den `austragungsplatzId` und `richterEinsaetze`.
## 4. Konflikt-Management
Das System arbeitet nach dem Prinzip **„Warnen statt Blockieren“** (ADR-0016):
- **Harte Fehler:** Nur bei technischer Unmöglichkeit (z.B. Datum in der Vergangenheit bei Live-Betrieb).
- **Warnungen:**
- Überlappung auf dem Platz.
- Richter hat gleichzeitig Einsatz in anderem Bewerb.
- Zeitunterschreitung für Reiter (Reiter startet in zwei kurz aufeinanderfolgenden Bewerben).
## 5. Synchronisation (Offline-First)
Änderungen am Zeitplan erzeugen `SyncEvents` (gemäß ADR-0022).
- **Lamport-Uhren:** Stellen sicher, dass bei gleichzeitigen Änderungen an zwei Laptops die zeitlich spätere Änderung (oder die mit höherer Priorität) gewinnt.
- **Echtzeit-Update:** Über WebSockets werden Zeitplan-Änderungen sofort an alle verbundenen Clients (z.B. Anzeige-Monitor, Richter-Tablet) gepusht.
## 6. Nächste Schritte
1. **Backend:** ✅ Implementiert (BewerbService, API, Zeitberechnungs-Pausen).
2. **Frontend:** Prototyping der Kalender-Ansicht mit Compose Desktop.
3. **QA:** Test-Szenarien für komplexe Verschiebungen (Kettenreaktionen bei „Anschließend“).
@@ -0,0 +1,46 @@
# Regelwerks-Check: Parcoursbesichtigung & Bewerbszusammenlegung
> **Status:** FINAL | **Datum:** 11. April 2026
> **Autor:** 📜 ÖTO/FEI Rulebook Expert
> **Kontext:** Zeitplan-Optimierung & Praxis-Szenarien
## 1. Parcoursbesichtigung zu Pferd
Die Aussage des Users wurde geprüft und bestätigt.
### 1.1 Regelwerks-Referenz (ÖTO 2026)
Gemäß **ÖTO Teil B, § 43 (Abweichungen)** gilt:
- **Normalfall:** Parcoursbesichtigung erfolgt zu Fuß.
- **Springpferde- & Jungpferdeprüfungen (bis 110 cm):** Eine Besichtigung **zu Pferd im Schritt** kann von der Richtergruppe erlaubt werden.
- **Zweck:** Junge, unerfahrene Pferde sollen stressfrei an optische Reize (Fangständer, Farben, Unterbauten) herangeführt werden.
### 1.2 Organisatorische Implikationen
- **Dauer:** Die Besichtigung zu Pferd benötigt oft etwas mehr Zeit für die Koordination am Einlass (15-20 Min. sind praxisnah).
- **Sicherheit:** Da Pferde im Parcours sind, während andere Reiter eventuell noch draußen warten, muss der Zeitplan einen Puffer von mindestens **5 Minuten** zwischen Ende Besichtigung und erstem Start vorsehen (ÖTO Vorschrift).
## 2. Zusammengelegte Besichtigungen (Cross-Competition Inspection)
In der Praxis üblich, wenn der Parcours für aufeinanderfolgende Bewerbe identisch bleibt.
### 2.1 Szenario
- **Bewerb A:** Springpferdeprüfung 105cm (Besichtigung zu Pferd erlaubt).
- **Bewerb B:** Standardspringprüfung 105cm (Besichtigung zu Fuß).
- **Lösung:** Eine gemeinsame Besichtigungszeit vor Bewerb A.
### 2.2 Regelwerks-Konformität
- Es gibt kein Verbot für gemeinsame Besichtigungen, solange die Teilnehmer beider Bewerbe die Möglichkeit haben, den Parcours regelkonform zu besichtigen.
- **Wichtig:** Wenn Bewerb B erst 2 Stunden später startet, muss für Teilnehmer von Bewerb B theoretisch eine zweite Besichtigungsmöglichkeit (oder ein "Refresh") angeboten werden, falls der Parcours zwischenzeitlich verändert wurde. Wenn er identisch bleibt, reicht die einmalige Bekanntgabe.
## 3. Anforderungen für die Software (Zeitplan-Modul)
### 3.1 Datenmodell
- `Abteilung` oder `Bewerb` benötigt ein Flag `besichtigungZuPferdErlaubt` (Boolean).
- `BesichtigungsBlock` als eigenständiges Entitätsobjekt im Zeitplan, das mit **mehreren** Bewerben/Abteilungen verknüpft werden kann.
### 3.2 Validierung & Warnungen
- **Warnung:** Wenn ein Besichtigungsblock mit "zu Pferd" markiert ist, aber ein verbundener Bewerb (z.B. L-Springen) dies laut ÖTO normalerweise nicht vorsieht (Diskretionsspielraum der Richter).
- **Puffer-Check:** Automatische Prüfung, ob zwischen `Ende Besichtigung` und `Start Erster Reiter` mindestens 5 Minuten liegen.
## 4. Fazit für Architect & Entwickler
Die "Besichtigung zu Pferd" ist ein valider Spezialfall für Springpferdeprüfungen. Die Software muss erlauben, Besichtigungszeiten flexibel vor einen oder mehrere Bewerbe zu lagern und den Typ (Fuß/Pferd) zu kennzeichnen.
---
*Geprüft durch den 📜 ÖTO/FEI Rulebook Expert am 11.04.2026*
@@ -0,0 +1,51 @@
# 🤖 Konzept: Status-Automat für Nennungen & Zeitplan-Synchronisation
Dieses Dokument spezifiziert die Logik des Status-Automaten für Nennungen (Sprint C-1) und dessen Auswirkungen auf den dynamischen Zeitplan.
## 1. Status-Definitionen (NennStatusE)
Basierend auf `core-domain/Enums.kt`:
| Status | Bedeutung | Auswirkung auf Zeitplan |
| :--- | :--- | :--- |
| `EINGEGANGEN` | Nennung wurde erstellt (Initialzustand) | Belegt Zeitslot (basierend auf `reitdauerMinuten`) |
| `BESTAETIGT` | Meldestelle hat Nennung geprüft | Belegt Zeitslot |
| `NACHNENNUNG` | Nennung nach Nennschluss | Belegt Zeitslot (ggf. am Ende der Liste) |
| `TRANSFERIERT` | Nennung wurde auf anderes Paar übertragen | **Inaktiv** (Original-Eintrag wird durch neuen ersetzt) |
| `ZURUECKGEZOGEN`| Reiter hat abgemeldet (vor Startlistenerstellung) | **Inaktiv** (Slot wird frei) |
| `GESTARTET` | Paar ist in die Prüfung eingeritten | Startzeitpunkt fixiert, Folgestarts ggf. anpassen |
| `NICHT_ANGETRETEN`| Paar ist zum Startzeitpunkt nicht erschienen | **Zeitslot verfällt** oder Folgestarts rücken nach |
## 2. Status-Übergänge & Validierung
### 2.1 Gültige Übergänge (Beispiele)
- `EINGEGANGEN` -> `BESTAETIGT` (Normalfall)
- `EINGEGANGEN` -> `ZURUECKGEZOGEN` (Abmeldung)
- `BESTAETIGT` -> `TRANSFERIERT` (Reitertausch)
- `BESTAETIGT` -> `GESTARTET` (Während des Turniers)
### 2.2 Side-Effects (Side-Effect-Engine)
Wenn sich der Status einer Nennung ändert, müssen folgende Systeme informiert werden:
1. **Billing-Service:** Bei `ZURUECKGEZOGEN` ggf. Stornogebühren prüfen. Bei `TRANSFERIERT` Guthaben übertragen.
2. **Startlisten-Service:** Bei `ZURUECKGEZOGEN` nach Veröffentlichung der Startliste -> Eintrag als `istGestrichen = true` markieren.
3. **Zeitplan-Optimierung:** Bei `NICHT_ANGETRETEN` -> Alle folgenden Startzeiten rücken um `reitdauerMinuten` nach vorne (sofern im Kalender-Modus "Dynamisch" aktiv ist).
## 3. Dynamische Zeitplan-Anpassung (C-1 Extension)
Der `StartlistenService` muss eine Methode `aktualisiereZeitplanNachStatusAenderung` erhalten:
- **Szenario A (Nicht angetreten):** Wenn Nennung X `NICHT_ANGETRETEN` wird, rücken alle folgenden Nennungen in der Abteilung nach vorne.
- **Szenario B (Verspätung):** Wenn Nennung X `GESTARTET` wird, aber 2 Minuten später als geplant, verschieben sich alle Folgetermine um +2 Minuten (Kettenreaktion).
### Puffer-Regel (ÖTO-Konformität)
- Eine dynamische Verschiebung nach *vorne* darf nie dazu führen, dass ein Reiter vor seiner ursprünglich kommunizierten Startzeit (oder einem definierten Puffer-Zeitraum von z.B. 15 Minuten) starten muss, ohne dass dies explizit bestätigt wurde.
## 4. Implementierungs-Leitfaden für Backend (C-1)
1. Erweiterung von `NennungUseCases.statusAendern` um Aufrufe der Side-Effect-Handler.
2. Implementierung des `NennungStatusListener` in `entries-service`.
3. Anbindung an den `StartlistenService` zur Zeitre-Kalkulation.
---
**Status:** Entwurf (Lead Architect)
**Datum:** 11. April 2026
+19 -12
View File
@@ -36,20 +36,27 @@
--- ---
## 🟠 Sprint C — Priorität 2 (nächste Woche) ## 🟠 Sprint C — In Arbeit
- [ ] **C-1** | Synchronisations-Protokoll-Konzeption - [x] **C-1** | Zeitplan-Optimierung Konzept
- [x] Offline-First-Konzept für Desktop ↔ Backend ausarbeiten - [x] Fachliche Anforderungen (Use Cases) definiert
- [x] Conflict-Resolution-Strategie definieren (gleichzeitige Änderungen) - [x] Zeitberechnungs-Algorithmus spezifiziert
- [x] Konzept-Dokument in `docs/01_Architecture/` ablegen → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md` - [x] Drag & Drop Logik für Kalender-Ansicht entworfen
- Verweis/Bezug: Baut auf ADR-0021 (Tenant) und ADR-0022 (LAN-Sync Lamport) auf; einheitliches `SyncEvent`-Modell Desktop↔Backend. - [x] Konzept-Dokument in `docs/01_Architecture/` abgelegt → `docs/01_Architecture/konzept-zeitplan-optimierung-de.md`
- [ ] **C-2** | MASTER_ROADMAP aktualisieren - [x] **C-2** | MASTER_ROADMAP aktualisieren
- [x] Desktop-App-Fokus eintragen - [x] Phase 9 Fortschritt reflektieren
- [x] Tenant-Isolation-Meilensteine (Sprint A Ergebnisse) als erledigt markieren - [x] Link zum Zeitplan-Konzept ergänzt
- [x] Offline-Sync-Meilensteine eintragen - [x] Feature-Migration (Frontend) dokumentiert
- [x] Phase 8 Fortschritt reflektieren - [ ] Weitere Sprints (D, E) grob skizzieren
- Update: Siehe `docs/01_Architecture/MASTER_ROADMAP.md` (Stand 2026-04-03) — Produktfokus ergänzt, ADR0021/0022 in ADRTabelle eingetragen, Phase8Status („Konzept/ADR erledigt“) markiert, Todo „OfflineFirst Desktop↔Backend“ verlinkt.
---
## 🟠 Sprint D — In Arbeit
- [ ] **D-1** | USB-Stick Fallback (Sync)
- [ ] Technische Machbarkeit (File-Storage vs. SQLite-Export) prüfen
- [ ] ADR für Offline-Transfer erstellen
--- ---
@@ -1,6 +1,6 @@
# 🗂️ Sprint Execution Order — Meldestelle-Biest # 🗂️ Sprint Execution Order — Meldestelle-Biest
> **Stand:** 10. April 2026 | **Phase:** 9 — Zeitplan & Protokollierung > **Stand:** 11. April 2026 | **Phase:** 9 — Zeitplan & Protokollierung
> **Erstellt von:** 🏗️ Lead Architect > **Erstellt von:** 🏗️ Lead Architect
> **Strategisches Ziel:** Desktop-MVP mit Event-First-Workflow, Offline-First, ÖTO-Konformität > **Strategisches Ziel:** Desktop-MVP mit Event-First-Workflow, Offline-First, ÖTO-Konformität
@@ -10,14 +10,14 @@
| Agent | Sprint A | Sprint B | Sprint C | Nächste Aktion | | Agent | Sprint A | Sprint B | Sprint C | Nächste Aktion |
|---------------|------------------|------------------------------------------|-------------------|-------------------------------------------------------| |---------------|------------------|------------------------------------------|-------------------|-------------------------------------------------------|
| 🏗️ Architect | ✅ Abgeschlossen | ✅ Abgeschlossen | ⬜ Nicht gestartet | Zeitplan-Optimierung Konzept | | 🏗️ Architect | ✅ Abgeschlossen | ✅ Abgeschlossen | 🟡 In Arbeit | Zeitplan-Optimierung (ADR/Konzept) |
| 👷 Backend | ✅ Abgeschlossen | ✅ B-1/B-2 fertig | ⬜ Nicht gestartet | C-1 Nennungs-Service Erweiterung | | 👷 Backend | ✅ Abgeschlossen | ✅ Abgeschlossen | 🟡 In Arbeit | C-1 Nennungs-Service Erweiterung |
| 🎨 Frontend | ✅ Abgeschlossen | 🟡 B-2/B-3 teilweise / B-4 offen | ⬜ Nicht gestartet | B-4 Kassa-Screen & StoreV2-Ablösung | | 🎨 Frontend | ✅ Abgeschlossen | B-2/B-3/B-4 fertig | 🟡 In Arbeit | C-2 Zeitplan Drag & Drop |
| 📜 Rulebook | ✅ Abgeschlossen | ✅ B-2 abgeschlossen | ⬜ Nicht gestartet | C-1 AltersklasseRechner | | 📜 Rulebook | ✅ Abgeschlossen | ✅ Abgeschlossen | ✅ C-1 fertig | Regelwerk-Validierung Zeitplan |
| 🐧 DevOps | ✅ Abgeschlossen | ✅ Abgeschlossen | ✅ C-1/C-2 fertig | C-3 Produktions-Deployment | | 🐧 DevOps | ✅ Abgeschlossen | ✅ Abgeschlossen | ✅ C-1/C-2 fertig | C-3 Produktions-Deployment |
| 🧐 QA | ✅ Abgeschlossen | ✅ B-1/B-3 fertig | ⬜ Nicht gestartet | B-2 Onboarding-Tests | | 🧐 QA | ✅ Abgeschlossen | ✅ Abgeschlossen | 🟡 In Arbeit | Zeitplan-Optimierung Tests |
| 🖌️ UI/UX | ✅ Abgeschlossen | 🔴 B-1/B-4 offen | ⬜ Nicht gestartet | B-4 Wireframes für Kassa-Screen | | 🖌️ UI/UX | ✅ Abgeschlossen | ✅ Abgeschlossen | ⬜ Nicht gestartet | C-1 Wireframes in Compose umsetzen |
| 🧹 Curator | ✅ Abgeschlossen | ✅ B-1/B-2 fertig | ⬜ Nicht gestartet | Session-Dokumentation & Changelog | | 🧹 Curator | ✅ Abgeschlossen | ✅ Abgeschlossen | 🟡 In Arbeit | Dokumentations-Audit |
--- ---
@@ -27,9 +27,7 @@ Diese Aufgaben blockieren andere Agenten und müssen zuerst erledigt werden:
| Priorität | Agent | Aufgabe | Blockiert | | Priorität | Agent | Aufgabe | Blockiert |
|-----------|---------------|-----------------------------------------------|---------------------------------------------------| |-----------|---------------|-----------------------------------------------|---------------------------------------------------|
| 🔴 P1 | 🖌️ UI/UX | B-4: Wireframes für Kassa-Screen | 🎨 Frontend: B-4 Kassa-Screen | | 🔴 P1 | 🎨 Frontend | C-2: Zeitplan Drag & Drop | 🧐 QA: Zeitplan-Tests |
| 🔴 P1 | 🏗️ Architect | C-1: Zeitplan-Optimierung Konzept | 👷 Backend: C-2; 🎨 Frontend: C-2 |
| 🔴 P1 | 🎨 Frontend | B-2: StoreV2-Ablösung | 🧐 QA: B-4 ViewModel-Tests |
--- ---
@@ -38,7 +36,7 @@ Diese Aufgaben blockieren andere Agenten und müssen zuerst erledigt werden:
### 🏗️ Architect ### 🏗️ Architect
1. ✅ **B-1** ADR-0022 LAN-Sync-Protokoll (Event-Sourcing vs. CRDT vs. Timestamp) 1. ✅ **B-1** ADR-0022 LAN-Sync-Protokoll (Event-Sourcing vs. CRDT vs. Timestamp)
2. 🔴 **C-1** Konzept für Zeitplan-Optimierung (Drag & Drop Logik) 2. **C-1** Konzept für Zeitplan-Optimierung (Drag & Drop Logik)
### 👷 Backend Developer ### 👷 Backend Developer
@@ -74,7 +72,8 @@ Diese Aufgaben blockieren andere Agenten und müssen zuerst erledigt werden:
### 🖌️ UI/UX Designer ### 🖌️ UI/UX Designer
1. ✅ **B-1** Entscheidung Editier-Formulare 1. ✅ **B-1** Entscheidung Editier-Formulare
2. 🔴 **B-4** Wireframes für Kassa-Screen (Veranstaltungs-Kassa) 2. **B-3** Wireframes für Kassa-Screen (Veranstaltungs-Kassa)
3. ✅ **B-4** Empty States Spezifikation
### 🧹 Curator ### 🧹 Curator
@@ -0,0 +1,33 @@
/**
* Feature-Modul: Funktionärs-Verwaltung (Desktop-only)
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.navigation)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(libs.bundles.kmp.common)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
}
}
@@ -0,0 +1,17 @@
package at.mocode.frontend.features.funktionaer.di
import at.mocode.frontend.features.funktionaer.presentation.*
import org.koin.dsl.module
val funktionaerModule = module {
single<FunktionaerRepository> { MockFunktionaerRepository() }
factory { FunktionaerViewModel(get()) }
}
class MockFunktionaerRepository : FunktionaerRepository {
override suspend fun list(): List<FunktionaerListItem> = listOf(
FunktionaerListItem(1, "Wolfgang Schier", "RICHTER", "G3"),
FunktionaerListItem(2, "Alice Schwab", "RICHTER", "INTERNATIONAL"),
FunktionaerListItem(3, "Dietmar Gstöttner", "PARCOURSBAUER", null)
)
}
@@ -0,0 +1,15 @@
package at.mocode.frontend.features.funktionaer.domain
data class Funktionaer(
val id: Long,
val vorname: String,
val nachname: String,
val richterNummer: String? = null,
val rollen: List<String> = emptyList(),
val richterQualifikation: String? = null,
val qualifiziertFuerSparten: List<String> = emptyList(),
val email: String? = null,
val telefon: String? = null,
val vereinsNummer: String? = null,
val istAktiv: Boolean = true
)
@@ -0,0 +1,103 @@
package at.mocode.frontend.features.funktionaer.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.*
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
@Composable
fun FunktionaerScreen(
viewModel: FunktionaerViewModel
) {
val state by viewModel.state.collectAsState()
MsMasterDetailLayout(
master = {
FunktionaerListContent(
state = state,
onSearchChange = { viewModel.send(FunktionaerIntent.SearchChanged(it)) },
onFunktionaerSelected = { viewModel.send(FunktionaerIntent.Select(it)) }
)
},
detail = {
if (state.selectedId != null) {
val selected = state.list.find { it.id == state.selectedId }
if (selected != null) {
FunktionaerDetailContent(selected)
}
} else {
PlaceholderContent(
title = "Kein Funktionär ausgewählt",
subtitle = "Wählen Sie einen Funktionär aus der Liste aus."
)
}
}
)
}
@Composable
private fun FunktionaerListContent(
state: FunktionaerState,
onSearchChange: (String) -> Unit,
onFunktionaerSelected: (Long) -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
MsFilterBar(
searchQuery = state.searchQuery,
onSearchQueryChange = onSearchChange,
resultCount = state.filtered.size
)
Spacer(Modifier.height(8.dp))
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
MsDataTable(
items = state.filtered,
columns = listOf(
MsColumnDefinition(
title = "Name",
weight = 1f,
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Rolle",
width = 150.dp,
cellRenderer = { Text(it.rolle, style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Lizenz",
width = 100.dp,
cellRenderer = { Text(it.lizenz ?: "-", style = MaterialTheme.typography.bodySmall) }
)
),
onRowClick = { onFunktionaerSelected(it.id) }
)
}
}
}
@Composable
private fun FunktionaerDetailContent(item: FunktionaerListItem) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(item.name, style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(8.dp))
Text("Rolle: ${item.rolle}", style = MaterialTheme.typography.bodyLarge)
item.lizenz?.let {
Text("Lizenz: $it", style = MaterialTheme.typography.bodyLarge)
}
Spacer(Modifier.height(24.dp))
Text("Weitere Details folgen in der nächsten Ausbaustufe.", style = MaterialTheme.typography.bodyMedium)
}
}
@@ -1,6 +1,6 @@
package at.mocode.nennung.feature.di package at.mocode.frontend.features.nennung.di
import at.mocode.nennung.feature.presentation.NennungViewModel import at.mocode.frontend.features.nennung.presentation.NennungViewModel
import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
@@ -1,4 +1,4 @@
package at.mocode.nennung.feature.domain package at.mocode.frontend.features.nennung.domain
// --- Pferd --- // --- Pferd ---
data class Pferd( data class Pferd(
@@ -1,6 +1,6 @@
package at.mocode.nennung.feature.presentation package at.mocode.frontend.features.nennung.presentation
import at.mocode.nennung.feature.domain.* import at.mocode.frontend.features.nennung.domain.*
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -1,4 +1,4 @@
package at.mocode.nennung.feature.presentation package at.mocode.frontend.features.nennung.presentation
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -17,7 +17,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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.nennung.feature.domain.* import at.mocode.frontend.features.nennung.domain.*
import kotlin.time.Duration.Companion.milliseconds
// Farben für Startwunsch-Markierung // Farben für Startwunsch-Markierung
private val FarbeVorne = Color(0xFFE8F5E9) // Grün private val FarbeVorne = Color(0xFFE8F5E9) // Grün
@@ -37,7 +38,7 @@ fun NennungsMaske(
// Status-Snackbar // Status-Snackbar
state.statusMeldung?.let { meldung -> state.statusMeldung?.let { meldung ->
LaunchedEffect(meldung) { LaunchedEffect(meldung) {
kotlinx.coroutines.delay(3000) kotlinx.coroutines.delay(3000.milliseconds)
viewModel.statusMeldungDismiss() viewModel.statusMeldungDismiss()
} }
} }
@@ -0,0 +1,8 @@
package at.mocode.frontend.features.pferde.di
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
import org.koin.dsl.module
val pferdeModule = module {
factory { PferdeViewModel() }
}
@@ -12,7 +12,12 @@ data class Pferd(
val geschlecht: Geschlecht = Geschlecht.WALLACH, val geschlecht: Geschlecht = Geschlecht.WALLACH,
val farbe: String = "", val farbe: String = "",
val geburtsjahr: Int? = null, val geburtsjahr: Int? = null,
val status: PferdeStatus = PferdeStatus.AKTIV val status: PferdeStatus = PferdeStatus.AKTIV,
val feiId: String? = null,
val oepsNummer: String? = null,
val vater: String? = null,
val mutter: String? = null,
val besitzer: String? = null
) )
enum class Geschlecht(val label: String) { enum class Geschlecht(val label: String) {
@@ -5,7 +5,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.* import at.mocode.frontend.core.designsystem.components.*
@@ -38,6 +37,9 @@ fun PferdeScreen(
onFarbeChange = viewModel::onEditFarbeChange, onFarbeChange = viewModel::onEditFarbeChange,
onGeburtsjahrChange = viewModel::onEditGeburtsjahrChange, onGeburtsjahrChange = viewModel::onEditGeburtsjahrChange,
onStatusChange = viewModel::onEditStatusChange, onStatusChange = viewModel::onEditStatusChange,
onFeiIdChange = viewModel::onEditFeiIdChange,
onOepsNummerChange = viewModel::onEditOepsNummerChange,
onBesitzerChange = viewModel::onEditBesitzerChange,
onSave = viewModel::onSave, onSave = viewModel::onSave,
onCancel = viewModel::onCancel onCancel = viewModel::onCancel
) )
@@ -105,6 +107,9 @@ private fun PferdeEditorContent(
onFarbeChange: (String) -> Unit, onFarbeChange: (String) -> Unit,
onGeburtsjahrChange: (String) -> Unit, onGeburtsjahrChange: (String) -> Unit,
onStatusChange: (PferdeStatus) -> Unit, onStatusChange: (PferdeStatus) -> Unit,
onFeiIdChange: (String) -> Unit,
onOepsNummerChange: (String) -> Unit,
onBesitzerChange: (String) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onCancel: () -> Unit onCancel: () -> Unit
) { ) {
@@ -134,6 +139,23 @@ private fun PferdeEditorContent(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editFeiId,
onValueChange = onFeiIdChange,
label = "FEI ID",
modifier = Modifier.weight(1f)
)
MsTextField(
value = uiState.editOepsNummer,
onValueChange = onOepsNummerChange,
label = "ÖPS Nummer",
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsEnumDropdown( MsEnumDropdown(
label = "Geschlecht", label = "Geschlecht",
@@ -160,15 +182,24 @@ private fun PferdeEditorContent(
label = "Geburtsjahr", label = "Geburtsjahr",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
MsTextField(
value = uiState.editBesitzer,
onValueChange = onBesitzerChange,
label = "Besitzer",
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(16.dp))
MsEnumDropdown( MsEnumDropdown(
label = "Status", label = "Status",
options = PferdeStatus.entries.toTypedArray(), options = PferdeStatus.entries.toTypedArray(),
selectedOption = uiState.editStatus, selectedOption = uiState.editStatus,
onOptionSelected = onStatusChange, onOptionSelected = onStatusChange,
optionLabel = { it.label }, optionLabel = { it.label },
modifier = Modifier.weight(1f) modifier = Modifier.width(300.dp)
) )
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
@@ -3,6 +3,7 @@ package at.mocode.frontend.features.pferde.presentation
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import at.mocode.frontend.features.pferde.domain.Geschlecht import at.mocode.frontend.features.pferde.domain.Geschlecht
import at.mocode.frontend.features.pferde.domain.Pferd import at.mocode.frontend.features.pferde.domain.Pferd
import at.mocode.frontend.features.pferde.domain.PferdeStatus import at.mocode.frontend.features.pferde.domain.PferdeStatus
@@ -21,13 +22,16 @@ data class PferdeUiState(
val editGeschlecht: Geschlecht = Geschlecht.WALLACH, val editGeschlecht: Geschlecht = Geschlecht.WALLACH,
val editFarbe: String = "", val editFarbe: String = "",
val editGeburtsjahr: String = "", val editGeburtsjahr: String = "",
val editStatus: PferdeStatus = PferdeStatus.AKTIV val editStatus: PferdeStatus = PferdeStatus.AKTIV,
val editFeiId: String = "",
val editOepsNummer: String = "",
val editBesitzer: String = ""
) )
/** /**
* ViewModel für die Pferde-Verwaltung. * ViewModel für die Pferde-Verwaltung.
*/ */
open class PferdeViewModel(initialLoad: Boolean = true) { open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
var uiState by mutableStateOf(PferdeUiState()) var uiState by mutableStateOf(PferdeUiState())
protected set protected set
@@ -60,10 +64,25 @@ open class PferdeViewModel(initialLoad: Boolean = true) {
editGeschlecht = pferd.geschlecht, editGeschlecht = pferd.geschlecht,
editFarbe = pferd.farbe, editFarbe = pferd.farbe,
editGeburtsjahr = pferd.geburtsjahr?.toString() ?: "", editGeburtsjahr = pferd.geburtsjahr?.toString() ?: "",
editStatus = pferd.status editStatus = pferd.status,
editFeiId = pferd.feiId ?: "",
editOepsNummer = pferd.oepsNummer ?: "",
editBesitzer = pferd.besitzer ?: ""
) )
} }
fun onEditFeiIdChange(value: String) {
uiState = uiState.copy(editFeiId = value)
}
fun onEditOepsNummerChange(value: String) {
uiState = uiState.copy(editOepsNummer = value)
}
fun onEditBesitzerChange(value: String) {
uiState = uiState.copy(editBesitzer = value)
}
fun onEditNameChange(value: String) { fun onEditNameChange(value: String) {
uiState = uiState.copy(editName = value) uiState = uiState.copy(editName = value)
} }
@@ -0,0 +1,8 @@
package at.mocode.frontend.features.reiter.di
import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
import org.koin.dsl.module
val reiterModule = module {
factory { ReiterViewModel() }
}
@@ -12,7 +12,13 @@ data class Reiter(
val satznummer: String?, val satznummer: String?,
val lizenz: LizenzKlasse = LizenzKlasse.KEINE, val lizenz: LizenzKlasse = LizenzKlasse.KEINE,
val sparte: Sparte = Sparte.KEINE, val sparte: Sparte = Sparte.KEINE,
val status: ReiterStatus = ReiterStatus.AKTIV val status: ReiterStatus = ReiterStatus.AKTIV,
val feiId: String? = null,
val oepsNummer: String? = null,
val geburtsdatum: String? = null,
val email: String? = null,
val telefon: String? = null,
val verein: String? = null
) { ) {
val name: String get() = "$vorname $nachname" val name: String get() = "$vorname $nachname"
} }
@@ -34,6 +34,12 @@ fun ReiterScreen(
onNachnameChange = viewModel::onEditNameChange, onNachnameChange = viewModel::onEditNameChange,
onLizenzChange = viewModel::onEditLizenzChange, onLizenzChange = viewModel::onEditLizenzChange,
onSparteChange = viewModel::onEditSparteChange, onSparteChange = viewModel::onEditSparteChange,
onFeiIdChange = viewModel::onEditFeiIdChange,
onOepsNummerChange = viewModel::onEditOepsNummerChange,
onGeburtsdatumChange = viewModel::onEditGeburtsdatumChange,
onEmailChange = viewModel::onEditEmailChange,
onTelefonChange = viewModel::onEditTelefonChange,
onVereinChange = viewModel::onEditVereinChange,
onSave = viewModel::onSave, onSave = viewModel::onSave,
onCancel = viewModel::onCancel onCancel = viewModel::onCancel
) )
@@ -104,6 +110,12 @@ private fun ReiterEditorContent(
onNachnameChange: (String) -> Unit, onNachnameChange: (String) -> Unit,
onLizenzChange: (LizenzKlasse) -> Unit, onLizenzChange: (LizenzKlasse) -> Unit,
onSparteChange: (Sparte) -> Unit, onSparteChange: (Sparte) -> Unit,
onFeiIdChange: (String) -> Unit,
onOepsNummerChange: (String) -> Unit,
onGeburtsdatumChange: (String) -> Unit,
onEmailChange: (String) -> Unit,
onTelefonChange: (String) -> Unit,
onVereinChange: (String) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onCancel: () -> Unit onCancel: () -> Unit
) { ) {
@@ -133,6 +145,57 @@ private fun ReiterEditorContent(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editFeiId,
onValueChange = onFeiIdChange,
label = "FEI ID",
modifier = Modifier.weight(1f)
)
MsTextField(
value = uiState.editOepsNummer,
onValueChange = onOepsNummerChange,
label = "ÖPS Nummer",
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editGeburtsdatum,
onValueChange = onGeburtsdatumChange,
label = "Geburtsdatum",
modifier = Modifier.weight(1f)
)
MsTextField(
value = uiState.editVerein,
onValueChange = onVereinChange,
label = "Verein",
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editEmail,
onValueChange = onEmailChange,
label = "E-Mail",
modifier = Modifier.weight(1f)
)
MsTextField(
value = uiState.editTelefon,
onValueChange = onTelefonChange,
label = "Telefon",
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsEnumDropdown( MsEnumDropdown(
label = "Lizenzklasse", label = "Lizenzklasse",
@@ -3,6 +3,7 @@ package at.mocode.frontend.features.reiter.presentation
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import at.mocode.frontend.features.reiter.domain.LizenzKlasse import at.mocode.frontend.features.reiter.domain.LizenzKlasse
import at.mocode.frontend.features.reiter.domain.Reiter import at.mocode.frontend.features.reiter.domain.Reiter
import at.mocode.frontend.features.reiter.domain.ReiterStatus import at.mocode.frontend.features.reiter.domain.ReiterStatus
@@ -21,14 +22,20 @@ data class ReiterUiState(
val editVorname: String = "", val editVorname: String = "",
val editLizenz: LizenzKlasse = LizenzKlasse.KEINE, val editLizenz: LizenzKlasse = LizenzKlasse.KEINE,
val editSparte: Sparte = Sparte.KEINE, val editSparte: Sparte = Sparte.KEINE,
val editStatus: ReiterStatus = ReiterStatus.AKTIV val editStatus: ReiterStatus = ReiterStatus.AKTIV,
val editFeiId: String = "",
val editOepsNummer: String = "",
val editGeburtsdatum: String = "",
val editEmail: String = "",
val editTelefon: String = "",
val editVerein: String = ""
) )
/** /**
* ViewModel für die Reiter-Verwaltung. * ViewModel für die Reiter-Verwaltung.
* In einem echten Szenario würden wir hier ein Repository injizieren. * In einem echten Szenario würden wir hier ein Repository injizieren.
*/ */
open class ReiterViewModel(initialLoad: Boolean = true) { open class ReiterViewModel(initialLoad: Boolean = true) : ViewModel() {
var uiState by mutableStateOf(ReiterUiState()) var uiState by mutableStateOf(ReiterUiState())
protected set protected set
@@ -62,10 +69,23 @@ open class ReiterViewModel(initialLoad: Boolean = true) {
editName = reiter.nachname, editName = reiter.nachname,
editLizenz = reiter.lizenz, editLizenz = reiter.lizenz,
editSparte = reiter.sparte, editSparte = reiter.sparte,
editStatus = reiter.status editStatus = reiter.status,
editFeiId = reiter.feiId ?: "",
editOepsNummer = reiter.oepsNummer ?: "",
editGeburtsdatum = reiter.geburtsdatum ?: "",
editEmail = reiter.email ?: "",
editTelefon = reiter.telefon ?: "",
editVerein = reiter.verein ?: ""
) )
} }
fun onEditFeiIdChange(value: String) { uiState = uiState.copy(editFeiId = value) }
fun onEditOepsNummerChange(value: String) { uiState = uiState.copy(editOepsNummer = value) }
fun onEditGeburtsdatumChange(value: String) { uiState = uiState.copy(editGeburtsdatum = value) }
fun onEditEmailChange(value: String) { uiState = uiState.copy(editEmail = value) }
fun onEditTelefonChange(value: String) { uiState = uiState.copy(editTelefon = value) }
fun onEditVereinChange(value: String) { uiState = uiState.copy(editVerein = value) }
fun onEditVornameChange(value: String) { fun onEditVornameChange(value: String) {
uiState = uiState.copy(editVorname = value) uiState = uiState.copy(editVorname = value)
} }
@@ -0,0 +1,44 @@
package at.mocode.frontend.features.veranstalter.data.remote
import at.mocode.frontend.features.veranstalter.domain.Veranstalter
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
/**
* Fake-Implementierung für die Veranstalter-Verwaltung (Offline-First Prototyp).
* Später durch Ktor-Backed Implementierung ersetzt.
*/
class FakeVeranstalterRepository : VeranstalterRepository {
private val mockData = mutableListOf(
Veranstalter(1, "URV Schloss Hof", "1-2345", "Schloßhof", "Aktiv"),
Veranstalter(2, "RV Schloß Rosenau", "3-0012", "Rosenau", "Aktiv"),
Veranstalter(3, "Reitclub Tulln", "3-1520", "Tulln", "Inaktiv"),
Veranstalter(4, "RC St. Pölten", "3-0101", "St. Pölten", "Aktiv"),
Veranstalter(5, "Union Reitklub Wien", "9-0001", "Wien", "Aktiv")
)
override suspend fun list(): Result<List<Veranstalter>> = Result.success(mockData)
override suspend fun getById(id: Long): Result<Veranstalter> {
return mockData.find { it.id == id }?.let { Result.success(it) }
?: Result.failure(Exception("Veranstalter nicht gefunden"))
}
override suspend fun create(model: Veranstalter): Result<Veranstalter> {
mockData.add(model)
return Result.success(model)
}
override suspend fun update(id: Long, model: Veranstalter): Result<Veranstalter> {
val index = mockData.indexOfFirst { it.id == id }
if (index != -1) {
mockData[index] = model
return Result.success(model)
}
return Result.failure(Exception("Veranstalter nicht gefunden"))
}
override suspend fun delete(id: Long): Result<Unit> {
mockData.removeIf { it.id == id }
return Result.success(Unit)
}
}
@@ -1,8 +1,5 @@
package at.mocode.frontend.features.veranstalter.domain package at.mocode.frontend.features.veranstalter.domain
/**
* Domänenmodell für Veranstalter (V3-Minimum für Listenansicht).
*/
data class Veranstalter( data class Veranstalter(
val id: Long, val id: Long,
val name: String, val name: String,
@@ -1,4 +1,4 @@
package at.mocode.veranstalter.feature.presentation package at.mocode.frontend.features.veranstalter.presentation
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -1,72 +0,0 @@
package at.mocode.frontend.features.veranstalter.data.remote
import at.mocode.frontend.core.network.*
import at.mocode.frontend.features.veranstalter.data.mapper.toDomain
import at.mocode.frontend.features.veranstalter.data.mapper.toDto
import at.mocode.frontend.features.veranstalter.data.remote.dto.VeranstalterDto
import at.mocode.frontend.features.veranstalter.domain.Veranstalter
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class DefaultVeranstalterRepository(
private val client: HttpClient,
) : VeranstalterRepository {
override suspend fun list(): Result<List<Veranstalter>> = runCatching {
val response = client.get(ApiRoutes.Veranstalter.ROOT)
when {
response.status.isSuccess() -> response.body<List<VeranstalterDto>>().map { it.toDomain() }
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<Veranstalter> = runCatching {
val response = client.get("${ApiRoutes.Veranstalter.ROOT}/$id")
when {
response.status.isSuccess() -> response.body<VeranstalterDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
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 create(model: Veranstalter): Result<Veranstalter> = runCatching {
val response = client.post(ApiRoutes.Veranstalter.ROOT) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<VeranstalterDto>().toDomain()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun update(id: Long, model: Veranstalter): Result<Veranstalter> = runCatching {
val response = client.put("${ApiRoutes.Veranstalter.ROOT}/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<VeranstalterDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.Veranstalter.ROOT}/$id")
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
}
@@ -1,10 +1,9 @@
package at.mocode.frontend.features.veranstalter.di package at.mocode.frontend.features.veranstalter.di
import at.mocode.frontend.features.veranstalter.data.remote.DefaultVeranstalterRepository import at.mocode.frontend.features.veranstalter.data.remote.FakeVeranstalterRepository
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
val veranstalterModule = module { val veranstalterModule = module {
single<VeranstalterRepository> { DefaultVeranstalterRepository(get(named("apiClient"))) } single<VeranstalterRepository> { FakeVeranstalterRepository() }
} }
@@ -1,4 +1,4 @@
package at.mocode.veranstalter.feature.presentation package at.mocode.frontend.features.veranstalter.presentation
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
@@ -1,4 +1,4 @@
package at.mocode.veranstalter.feature.presentation package at.mocode.frontend.features.veranstalter.presentation
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@@ -1,4 +1,4 @@
package at.mocode.veranstalter.feature.presentation package at.mocode.frontend.features.veranstalter.presentation
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -1,4 +1,4 @@
package at.mocode.veranstalter.feature.presentation package at.mocode.frontend.features.veranstalter.presentation
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -1,15 +1,13 @@
package at.mocode.veranstalter.feature.presentation package at.mocode.frontend.features.veranstalter.presentation
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -1,4 +1,4 @@
package at.mocode.veranstalter.feature.presentation package at.mocode.frontend.features.veranstalter.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -1,4 +1,4 @@
package at.mocode.veranstalter.feature.presentation package at.mocode.frontend.features.veranstalter.presentation
import at.mocode.frontend.core.designsystem.models.LoginStatus import at.mocode.frontend.core.designsystem.models.LoginStatus
@@ -1,4 +1,4 @@
package at.mocode.veranstalter.feature.presentation package at.mocode.frontend.features.veranstalter.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -1 +0,0 @@
// Deprecated fake repository removed in favor of real Ktor-backed implementation.
@@ -52,17 +52,19 @@ kotlin {
implementation(projects.core.znsParser) implementation(projects.core.znsParser)
// Feature-Module // Feature-Module
implementation(projects.frontend.features.nennungFeature)
implementation(projects.frontend.features.pingFeature) implementation(projects.frontend.features.pingFeature)
implementation(projects.frontend.features.nennungFeature)
implementation(projects.frontend.features.znsImportFeature) implementation(projects.frontend.features.znsImportFeature)
implementation(projects.frontend.features.veranstalterFeature) implementation(projects.frontend.features.veranstalterFeature)
implementation(projects.frontend.features.veranstaltungFeature) implementation(projects.frontend.features.veranstaltungFeature)
implementation(projects.frontend.features.funktionaerFeature)
implementation(projects.frontend.features.profileFeature)
implementation(projects.frontend.features.reiterFeature)
implementation(projects.frontend.features.pferdeFeature)
implementation(projects.frontend.features.vereinFeature)
implementation(projects.frontend.features.turnierFeature) implementation(projects.frontend.features.turnierFeature)
implementation(project(":frontend:features:profile-feature")) implementation(projects.frontend.features.billingFeature)
implementation(project(":frontend:features:reiter-feature"))
implementation(project(":frontend:features:pferde-feature"))
implementation(project(":frontend:features:billing-feature"))
implementation(project(":frontend:features:verein-feature"))
// Compose Desktop // Compose Desktop
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
@@ -14,7 +14,9 @@ import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.frontend.features.billing.di.billingModule import at.mocode.frontend.features.billing.di.billingModule
import at.mocode.frontend.features.profile.di.profileModule import at.mocode.frontend.features.profile.di.profileModule
import at.mocode.frontend.features.verein.di.vereinFeatureModule import at.mocode.frontend.features.verein.di.vereinFeatureModule
import at.mocode.nennung.feature.di.nennungFeatureModule import at.mocode.frontend.features.nennung.di.nennungFeatureModule
import at.mocode.frontend.features.pferde.di.pferdeModule
import at.mocode.frontend.features.reiter.di.reiterModule
import at.mocode.ping.feature.di.pingFeatureModule import at.mocode.ping.feature.di.pingFeatureModule
import at.mocode.zns.feature.di.znsImportModule import at.mocode.zns.feature.di.znsImportModule
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -36,6 +38,8 @@ fun main() = application {
znsImportModule, znsImportModule,
profileModule, profileModule,
billingModule, billingModule,
pferdeModule,
reiterModule,
vereinFeatureModule, vereinFeatureModule,
desktopModule, desktopModule,
) )
@@ -21,14 +21,19 @@ import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.navigation.AppScreen import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.features.billing.presentation.BillingScreen import at.mocode.frontend.features.billing.presentation.BillingScreen
import at.mocode.frontend.features.billing.presentation.BillingViewModel import at.mocode.frontend.features.billing.presentation.BillingViewModel
import at.mocode.frontend.features.nennung.presentation.NennungViewModel
import at.mocode.frontend.features.nennung.presentation.NennungsMaske
import at.mocode.frontend.features.pferde.presentation.PferdeScreen
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
import at.mocode.frontend.features.profile.presentation.ProfileScreen import at.mocode.frontend.features.profile.presentation.ProfileScreen
import at.mocode.frontend.features.profile.presentation.ProfileViewModel import at.mocode.frontend.features.profile.presentation.ProfileViewModel
import at.mocode.frontend.features.reiter.presentation.ReiterScreen
import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
import at.mocode.frontend.features.verein.presentation.VereinScreen 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.TurnierDetailScreen import at.mocode.turnier.feature.presentation.TurnierDetailScreen
import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore
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
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
@@ -379,6 +384,8 @@ private fun DesktopContentArea(
// Onboarding ohne Login // Onboarding ohne Login
is AppScreen.Onboarding -> { is AppScreen.Onboarding -> {
val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject() val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject()
at.mocode.frontend.core.designsystem.theme.AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
at.mocode.desktop.v2.OnboardingScreen( at.mocode.desktop.v2.OnboardingScreen(
geraetName = obGeraet, geraetName = obGeraet,
secureKey = obKey, secureKey = obKey,
@@ -389,6 +396,8 @@ private fun DesktopContentArea(
onNavigate(AppScreen.VeranstaltungVerwaltung) onNavigate(AppScreen.VeranstaltungVerwaltung)
} }
} }
}
}
// Haupt-Zentrale: Veranstaltung-Verwaltung // Haupt-Zentrale: Veranstaltung-Verwaltung
is AppScreen.VeranstaltungVerwaltung -> { is AppScreen.VeranstaltungVerwaltung -> {
@@ -412,37 +421,50 @@ private fun DesktopContentArea(
} }
// --- Pferde-Verwaltung & Profil --- // --- Pferde-Verwaltung & Profil ---
is AppScreen.PferdVerwaltung -> at.mocode.desktop.v2.PferdeVerwaltungScreen( is AppScreen.PferdVerwaltung -> {
onBack = onBack, val viewModel = koinViewModel<PferdeViewModel>()
onEdit = { onNavigate(AppScreen.PferdProfil(it)) } PferdeScreen(viewModel = viewModel)
) }
is AppScreen.PferdProfil -> at.mocode.desktop.v2.PferdProfilV2( is AppScreen.PferdProfil -> {
id = currentScreen.id, val viewModel = koinViewModel<PferdeViewModel>()
onBack = onBack, // In der aktuellen Ausbaustufe wählen wir das Pferd im ViewModel aus
) LaunchedEffect(currentScreen.id) {
// Mock: Wir suchen das Pferd in den Suchergebnissen
viewModel.uiState.searchResults.find { it.id == currentScreen.id.toString() }?.let {
viewModel.selectPferd(it)
}
}
PferdeScreen(viewModel = viewModel)
}
// --- Reiter-Verwaltung & Profil --- // --- Reiter-Verwaltung & Profil ---
is AppScreen.ReiterVerwaltung -> at.mocode.desktop.v2.ReiterVerwaltungScreen( is AppScreen.ReiterVerwaltung -> {
onBack = onBack, val viewModel = koinViewModel<ReiterViewModel>()
onEdit = { onNavigate(AppScreen.ReiterProfil(it)) } ReiterScreen(viewModel = viewModel)
) }
is AppScreen.ReiterProfil -> at.mocode.desktop.v2.ReiterProfilV2( is AppScreen.ReiterProfil -> {
id = currentScreen.id, val viewModel = koinViewModel<ReiterViewModel>()
onBack = onBack, LaunchedEffect(currentScreen.id) {
) viewModel.uiState.searchResults.find { it.id == currentScreen.id.toString() }?.let {
viewModel.selectReiter(it)
}
}
ReiterScreen(viewModel = viewModel)
}
// --- Verein-Verwaltung & Profil --- // --- Verein-Verwaltung & Profil ---
is AppScreen.VereinVerwaltung -> at.mocode.desktop.v2.VereinVerwaltungScreen( is AppScreen.VereinVerwaltung -> {
onBack = onBack, val vereinViewModel: VereinViewModel = koinViewModel()
onEdit = { onNavigate(AppScreen.VereinProfil(it)) } VereinScreen(viewModel = vereinViewModel)
) }
is AppScreen.VereinProfil -> at.mocode.desktop.v2.VereinProfilV2( is AppScreen.VereinProfil -> {
id = currentScreen.id, val vereinViewModel: VereinViewModel = koinViewModel()
onBack = onBack, // Mock: Selektion im ViewModel (falls unterstützt)
) VereinScreen(viewModel = vereinViewModel)
}
// --- Funktionaer-Verwaltung & Profil --- // --- Funktionaer-Verwaltung & Profil ---
is AppScreen.FunktionaerVerwaltung -> at.mocode.desktop.v2.FunktionaerVerwaltungScreen( is AppScreen.FunktionaerVerwaltung -> at.mocode.desktop.v2.FunktionaerVerwaltungScreen(
@@ -488,7 +510,7 @@ private fun DesktopContentArea(
) )
is AppScreen.VeranstalterDetail -> { is AppScreen.VeranstalterDetail -> {
val vId = currentScreen.veranstalterId val vId = currentScreen.veranstalterId
if (!FakeVeranstalterStore.exists(vId)) { if (vId != 1L) { // Temporärer Check für Mock-Daten
InvalidContextNotice( InvalidContextNotice(
message = "Veranstalter (ID=$vId) nicht gefunden.", message = "Veranstalter (ID=$vId) nicht gefunden.",
onBack = onBack onBack = onBack
@@ -664,6 +686,14 @@ private fun DesktopContentArea(
) )
} }
is AppScreen.Nennung -> {
val nennungViewModel: NennungViewModel = koinViewModel()
NennungsMaske(
viewModel = nennungViewModel,
onAbrechnungOeffnen = { /* Navigation zu Billing falls nötig */ }
)
}
// Fallback → Root // Fallback → Root
else -> AdminUebersichtScreen( else -> AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) }, onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
@@ -7,9 +7,9 @@ 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.presentation.* import at.mocode.turnier.feature.presentation.*
import at.mocode.zns.parser.ZnsBewerb import at.mocode.zns.parser.ZnsBewerb
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen import at.mocode.frontend.features.veranstalter.presentation.VeranstalterAuswahlScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen import at.mocode.frontend.features.veranstalter.presentation.VeranstalterNeuScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen
import at.mocode.wui.preview.ComponentPreview import at.mocode.wui.preview.ComponentPreview
+1
View File
@@ -137,6 +137,7 @@ include(":frontend:features:nennung-feature")
include(":frontend:features:zns-import-feature") include(":frontend:features:zns-import-feature")
include(":frontend:features:veranstalter-feature") include(":frontend:features:veranstalter-feature")
include(":frontend:features:veranstaltung-feature") include(":frontend:features:veranstaltung-feature")
include(":frontend:features:funktionaer-feature")
include(":frontend:features:profile-feature") include(":frontend:features:profile-feature")
include(":frontend:features:reiter-feature") include(":frontend:features:reiter-feature")
include(":frontend:features:pferde-feature") include(":frontend:features:pferde-feature")