Compare commits

...

7 Commits

Author SHA1 Message Date
2c8d16b27f Refactor DefaultVeranstalterRepository to use exception classes instead of objects. Clean up unused imports in VeranstalterModule and VeranstalterAuswahlV2.
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-03 01:12:14 +02:00
f82dbd64a5 Integrate Ktor HTTP clients and repositories for Veranstalter and Turnier features:
- Add `ApiRoutes` for central backend routing configuration.
- Implement `DefaultVeranstalterRepository` and `DefaultTurnierRepository` with Ktor clients.
- Add domain models (`Turnier`, `Bewerb`, `Abteilung`, `Veranstalter`) and respective repository interfaces.
- Replace fake VeranstalterRepository with real implementation.
- Update DI with `veranstalterModule` and HTTP client injection.
- Simplify TokenProvider and update HttpClient setup (timeouts, retries, logging).
- Mark roadmap tasks B-2 as partially complete.
2026-04-03 01:09:35 +02:00
a5c1fb5bae Mark Sprint B-1 and B-2 tasks as complete: finalize CI/CD pipeline for headless Compose Desktop tests, implement Gradle build optimizations, and update roadmap. Add new Gitea Actions workflow for desktop builds and tests. 2026-04-03 00:48:58 +02:00
62c0d9d75c Mark sprint tasks A-1 and parts of B-1 through B-3 as complete. Finalize design inventory, add Editier-Formulare guidelines, Bewerb creation workflow with Abteilungs-Logik, and Veranstaltungs-Kassa wireframes to documentation. 2026-04-03 00:39:59 +02:00
2b3e2d8c1b Implement MVVM for all V3 screens: add ViewModels for Turniere, Bewerbe, Abteilungen, Pferde, Reiter, Vereins, and Funktionaer workflows. Update roadmap to mark B-1 tasks as complete. 2026-04-03 00:26:02 +02:00
48ffadaaa2 Remove unused imports in BewerbRepositoryImpl and TurnierService. 2026-04-03 00:07:55 +02:00
c483f4925d Introduce tournament structure management for Entries Service:
- Add multi-layered entity support for `Turniere`, `Bewerbe`, and `Abteilungen` with tenant isolation.
- Implement Flyway schema migrations with constraints, indices, and default values for `Turniere`.
- Add Kotlin repositories and services for CRUD operations and validation across entities.
- Ensure tenant-safe transactions and implement new exception handling for `LockedException` and `ValidationException`.
- Provide REST APIs with controllers for managing lifecycle, hierarchy, and relationships between entities (`Turniere`, `Bewerbe`, and `Abteilungen`).
- Update Spring configuration with dependency wiring for new services and repositories.
2026-04-03 00:06:38 +02:00
52 changed files with 2490 additions and 113 deletions

View File

@ -0,0 +1,63 @@
name: Desktop CI — Headless Tests & Build
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
desktop-tests:
name: Compose Desktop — Tests (headless) & Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21 (Temurin)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
.gradle
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Show Gradle version
run: ./gradlew --version
- name: Run Desktop tests headless (Xvfb)
env:
_JAVA_OPTIONS: -Djava.awt.headless=true
run: |
sudo apt-get update -y
sudo apt-get install -y Xvfb
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
./gradlew :frontend:shells:meldestelle-desktop:jvmTest --stacktrace --no-daemon
- name: Build Desktop shell (Release)
env:
_JAVA_OPTIONS: -Djava.awt.headless=true
run: |
./gradlew :frontend:shells:meldestelle-desktop:build --stacktrace --no-daemon
- name: Upload build artifacts (Desktop shell)
uses: actions/upload-artifact@v4
with:
name: desktop-shell-build
path: |
frontend/shells/meldestelle-desktop/build/libs/**/*.jar
frontend/shells/meldestelle-desktop/build/compose*/**
if-no-files-found: warn

View File

@ -0,0 +1,21 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.abteilungen
import kotlin.uuid.Uuid
data class Abteilung(
val id: Uuid,
val bewerbId: Uuid,
val nr: Int,
val bezeichnung: String,
val typ: String,
)
interface AbteilungRepository {
suspend fun create(a: Abteilung): Abteilung
suspend fun findById(id: Uuid): Abteilung?
suspend fun findByBewerbId(bewerbId: Uuid): List<Abteilung>
suspend fun update(a: Abteilung): Abteilung
suspend fun delete(id: Uuid): Boolean
}

View File

@ -0,0 +1,64 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.abteilungen
import at.mocode.entries.service.persistence.AbteilungTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
class AbteilungRepositoryImpl : AbteilungRepository {
private fun rowToAbteilung(row: ResultRow): Abteilung = Abteilung(
id = row[AbteilungTable.id].toKotlinUuid(),
bewerbId = row[AbteilungTable.bewerbId].toKotlinUuid(),
nr = row[AbteilungTable.nr],
bezeichnung = row[AbteilungTable.bezeichnung],
typ = row[AbteilungTable.typ]
)
override suspend fun create(a: Abteilung): Abteilung = tenantTransaction {
val now = Clock.System.now()
AbteilungTable.insert { s ->
s[AbteilungTable.id] = a.id.toJavaUuid()
s[AbteilungTable.bewerbId] = a.bewerbId.toJavaUuid()
s[AbteilungTable.nr] = a.nr
s[AbteilungTable.bezeichnung] = a.bezeichnung
s[AbteilungTable.typ] = a.typ
s[AbteilungTable.createdAt] = now
s[AbteilungTable.updatedAt] = now
}
AbteilungTable.selectAll().where { AbteilungTable.id eq a.id.toJavaUuid() }.map(::rowToAbteilung).single()
}
override suspend fun findById(id: Uuid): Abteilung? = tenantTransaction {
AbteilungTable.selectAll().where { AbteilungTable.id eq id.toJavaUuid() }.map(::rowToAbteilung).singleOrNull()
}
override suspend fun findByBewerbId(bewerbId: Uuid): List<Abteilung> = tenantTransaction {
AbteilungTable.selectAll().where { AbteilungTable.bewerbId eq bewerbId.toJavaUuid() }.map(::rowToAbteilung)
}
override suspend fun update(a: Abteilung): Abteilung = tenantTransaction {
val now = Clock.System.now()
AbteilungTable.update({ AbteilungTable.id eq a.id.toJavaUuid() }) { s ->
s[AbteilungTable.nr] = a.nr
s[AbteilungTable.bezeichnung] = a.bezeichnung
s[AbteilungTable.typ] = a.typ
s[AbteilungTable.updatedAt] = now
}
AbteilungTable.selectAll().where { AbteilungTable.id eq a.id.toJavaUuid() }.map(::rowToAbteilung).single()
}
override suspend fun delete(id: Uuid): Boolean = tenantTransaction {
AbteilungTable.deleteWhere { AbteilungTable.id eq id.toJavaUuid() } > 0
}
}

View File

@ -0,0 +1,48 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.abteilungen
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
import kotlin.uuid.Uuid
data class CreateAbteilungRequest(
val nr: Int,
val bezeichnung: String,
val typ: String,
)
data class UpdateAbteilungRequest(
val nr: Int,
val bezeichnung: String,
val typ: String,
)
@RestController
class AbteilungenController(
private val service: AbteilungenService
) {
@PostMapping("/bewerbe/{bewerbId}/abteilungen")
@ResponseStatus(HttpStatus.CREATED)
suspend fun create(
@PathVariable bewerbId: String,
@RequestBody body: CreateAbteilungRequest
): Abteilung = service.create(Uuid.parse(bewerbId), body.nr, body.bezeichnung, body.typ)
@GetMapping("/bewerbe/{bewerbId}/abteilungen")
suspend fun list(@PathVariable bewerbId: String): List<Abteilung> = service.list(Uuid.parse(bewerbId))
@GetMapping("/abteilungen/{id}")
suspend fun get(@PathVariable id: String): Abteilung = service.get(Uuid.parse(id))
@PutMapping("/abteilungen/{id}")
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateAbteilungRequest): Abteilung =
service.update(Uuid.parse(id), body.nr, body.bezeichnung, body.typ)
@DeleteMapping("/abteilungen/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
suspend fun delete(@PathVariable id: String) {
service.delete(Uuid.parse(id))
}
}

View File

@ -0,0 +1,49 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.abteilungen
import at.mocode.entries.service.bewerbe.BewerbRepository
import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.persistence.BewerbTable
import at.mocode.entries.service.persistence.TurnierTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
class AbteilungenService(
private val repo: AbteilungRepository,
private val bewerbRepo: BewerbRepository,
) {
private suspend fun isParentTurnierPublished(bewerbId: Uuid): Boolean = tenantTransaction {
val bRow = BewerbTable.selectAll().where { BewerbTable.id eq bewerbId.toJavaUuid() }.singleOrNull()
?: return@tenantTransaction false
val tId = bRow[BewerbTable.turnierId]
val tRow = TurnierTable.selectAll().where { TurnierTable.id eq tId }.singleOrNull()
tRow?.get(TurnierTable.status) == "PUBLISHED"
}
suspend fun create(bewerbId: Uuid, nr: Int, bezeichnung: String, typ: String): Abteilung {
if (isParentTurnierPublished(bewerbId)) throw LockedException("Turnier ist PUBLISHED Abteilungen können nicht angelegt werden")
val a = Abteilung(id = Uuid.random(), bewerbId = bewerbId, nr = nr, bezeichnung = bezeichnung, typ = typ)
return repo.create(a)
}
suspend fun list(bewerbId: Uuid): List<Abteilung> = repo.findByBewerbId(bewerbId)
suspend fun get(id: Uuid): Abteilung = repo.findById(id) ?: throw NoSuchElementException("Abteilung $id nicht gefunden")
suspend fun update(id: Uuid, nr: Int, bezeichnung: String, typ: String): Abteilung {
val current = get(id)
if (isParentTurnierPublished(current.bewerbId)) throw LockedException("Turnier ist PUBLISHED Abteilungen können nicht geändert werden")
return repo.update(current.copy(nr = nr, bezeichnung = bezeichnung, typ = typ))
}
suspend fun delete(id: Uuid) {
val current = get(id)
if (isParentTurnierPublished(current.bewerbId)) throw LockedException("Turnier ist PUBLISHED Abteilungen können nicht gelöscht werden")
repo.delete(id)
}
}

View File

@ -0,0 +1,22 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.bewerbe
import kotlin.uuid.Uuid
data class Bewerb(
val id: Uuid,
val turnierId: Uuid,
val klasse: String,
val hoeheCm: Int?,
val bezeichnung: String,
)
interface BewerbRepository {
suspend fun create(b: Bewerb): Bewerb
suspend fun findById(id: Uuid): Bewerb?
suspend fun findByTurnierId(turnierId: Uuid, klasse: String? = null, q: String? = null): List<Bewerb>
suspend fun update(b: Bewerb): Bewerb
suspend fun delete(id: Uuid): Boolean
suspend fun countAbteilungen(id: Uuid): Long
}

View File

@ -0,0 +1,72 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.bewerbe
import at.mocode.entries.service.persistence.AbteilungTable
import at.mocode.entries.service.persistence.BewerbTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
class BewerbRepositoryImpl : BewerbRepository {
private fun rowToBewerb(row: ResultRow): Bewerb = Bewerb(
id = row[BewerbTable.id].toKotlinUuid(),
turnierId = row[BewerbTable.turnierId].toKotlinUuid(),
klasse = row[BewerbTable.klasse],
hoeheCm = row[BewerbTable.hoeheCm],
bezeichnung = row[BewerbTable.bezeichnung]
)
override suspend fun create(b: Bewerb): Bewerb = tenantTransaction {
val now = Clock.System.now()
BewerbTable.insert { s ->
s[BewerbTable.id] = b.id.toJavaUuid()
s[BewerbTable.turnierId] = b.turnierId.toJavaUuid()
s[BewerbTable.klasse] = b.klasse
s[BewerbTable.hoeheCm] = b.hoeheCm
s[BewerbTable.bezeichnung] = b.bezeichnung
s[BewerbTable.createdAt] = now
s[BewerbTable.updatedAt] = now
}
BewerbTable.selectAll().where { BewerbTable.id eq b.id.toJavaUuid() }.map(::rowToBewerb).single()
}
override suspend fun findById(id: Uuid): Bewerb? = tenantTransaction {
BewerbTable.selectAll().where { BewerbTable.id eq id.toJavaUuid() }.map(::rowToBewerb).singleOrNull()
}
override suspend fun findByTurnierId(turnierId: Uuid, klasse: String?, q: String?): List<Bewerb> = tenantTransaction {
var qy = BewerbTable.selectAll().where { BewerbTable.turnierId eq turnierId.toJavaUuid() }
if (klasse != null) qy = qy.where { BewerbTable.klasse eq klasse }
// q-Filter vorerst ausgelassen; wird bei Bedarf mit ILIKE ergänzt
qy.map(::rowToBewerb)
}
override suspend fun update(b: Bewerb): Bewerb = tenantTransaction {
val now = Clock.System.now()
BewerbTable.update({ BewerbTable.id eq b.id.toJavaUuid() }) { s ->
s[BewerbTable.klasse] = b.klasse
s[BewerbTable.hoeheCm] = b.hoeheCm
s[BewerbTable.bezeichnung] = b.bezeichnung
s[BewerbTable.updatedAt] = now
}
BewerbTable.selectAll().where { BewerbTable.id eq b.id.toJavaUuid() }.map(::rowToBewerb).single()
}
override suspend fun delete(id: Uuid): Boolean = tenantTransaction {
BewerbTable.deleteWhere { BewerbTable.id eq id.toJavaUuid() } > 0
}
override suspend fun countAbteilungen(id: Uuid): Long = tenantTransaction {
AbteilungTable.selectAll().where { AbteilungTable.bewerbId eq id.toJavaUuid() }.count()
}
}

View File

@ -0,0 +1,54 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.bewerbe
import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.persistence.TurnierTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
class BewerbService(
private val repo: BewerbRepository,
private val nennungen: NennungRepository,
) {
private suspend fun isTurnierPublished(turnierId: Uuid): Boolean = tenantTransaction {
val row = TurnierTable.selectAll().where { TurnierTable.id eq turnierId.toJavaUuid() }.singleOrNull()
row?.get(TurnierTable.status) == "PUBLISHED"
}
suspend fun create(turnierId: Uuid, klasse: String, hoeheCm: Int?, bezeichnung: String): Bewerb {
if (isTurnierPublished(turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht angelegt werden")
val b = Bewerb(
id = Uuid.random(),
turnierId = turnierId,
klasse = klasse,
hoeheCm = hoeheCm,
bezeichnung = bezeichnung
)
return repo.create(b)
}
suspend fun list(turnierId: Uuid, klasse: String?, q: String?): List<Bewerb> = repo.findByTurnierId(turnierId, klasse, q)
suspend fun get(id: Uuid): Bewerb = repo.findById(id) ?: throw NoSuchElementException("Bewerb $id nicht gefunden")
suspend fun update(id: Uuid, klasse: String, hoeheCm: Int?, bezeichnung: String): Bewerb {
val current = get(id)
if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht geändert werden")
return repo.update(current.copy(klasse = klasse, hoeheCm = hoeheCm, bezeichnung = bezeichnung))
}
suspend fun delete(id: Uuid) {
val current = get(id)
if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht gelöscht werden")
val abtCount = repo.countAbteilungen(id)
val nennCount = nennungen.countByBewerbId(id)
if (abtCount > 0L || nennCount > 0L) throw IllegalStateException("Bewerb hat Abteilungen oder Nennungen (409)")
repo.delete(id)
}
}

View File

@ -0,0 +1,52 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.bewerbe
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
import kotlin.uuid.Uuid
data class CreateBewerbRequest(
val klasse: String,
val hoeheCm: Int? = null,
val bezeichnung: String,
)
data class UpdateBewerbRequest(
val klasse: String,
val hoeheCm: Int? = null,
val bezeichnung: String,
)
@RestController
class BewerbeController(
private val service: BewerbService
) {
@PostMapping("/turniere/{turnierId}/bewerbe")
@ResponseStatus(HttpStatus.CREATED)
suspend fun create(
@PathVariable turnierId: String,
@RequestBody body: CreateBewerbRequest
): Bewerb = service.create(Uuid.parse(turnierId), body.klasse, body.hoeheCm, body.bezeichnung)
@GetMapping("/turniere/{turnierId}/bewerbe")
suspend fun list(
@PathVariable turnierId: String,
@RequestParam(required = false) klasse: String?,
@RequestParam(required = false) q: String?,
): List<Bewerb> = service.list(Uuid.parse(turnierId), klasse, q)
@GetMapping("/bewerbe/{id}")
suspend fun get(@PathVariable id: String): Bewerb = service.get(Uuid.parse(id))
@PutMapping("/bewerbe/{id}")
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateBewerbRequest): Bewerb =
service.update(Uuid.parse(id), body.klasse, body.hoeheCm, body.bezeichnung)
@DeleteMapping("/bewerbe/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
suspend fun delete(@PathVariable id: String) {
service.delete(Uuid.parse(id))
}
}

View File

@ -4,6 +4,15 @@ import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.domain.repository.NennungsTransferRepository
import at.mocode.entries.service.persistence.NennungRepositoryImpl
import at.mocode.entries.service.persistence.NennungsTransferRepositoryImpl
import at.mocode.entries.service.turniere.TurnierRepository
import at.mocode.entries.service.turniere.TurnierRepositoryImpl
import at.mocode.entries.service.turniere.TurnierService
import at.mocode.entries.service.bewerbe.BewerbRepository
import at.mocode.entries.service.bewerbe.BewerbRepositoryImpl
import at.mocode.entries.service.bewerbe.BewerbService
import at.mocode.entries.service.abteilungen.AbteilungRepository
import at.mocode.entries.service.abteilungen.AbteilungRepositoryImpl
import at.mocode.entries.service.abteilungen.AbteilungenService
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@ -21,4 +30,31 @@ class EntriesBeansConfiguration {
@Bean
fun nennungsTransferRepository(): NennungsTransferRepository = NennungsTransferRepositoryImpl()
@Bean
fun turnierRepository(): TurnierRepository = TurnierRepositoryImpl()
@Bean
fun turnierService(
turnierRepository: TurnierRepository,
nennungRepository: NennungRepository
): TurnierService = TurnierService(turnierRepository, nennungRepository)
@Bean
fun bewerbRepository(): BewerbRepository = BewerbRepositoryImpl()
@Bean
fun bewerbService(
bewerbRepository: BewerbRepository,
nennungRepository: NennungRepository
): BewerbService = BewerbService(bewerbRepository, nennungRepository)
@Bean
fun abteilungRepository(): AbteilungRepository = AbteilungRepositoryImpl()
@Bean
fun abteilungenService(
abteilungRepository: AbteilungRepository,
bewerbRepository: BewerbRepository
): AbteilungenService = AbteilungenService(abteilungRepository, bewerbRepository)
}

View File

@ -5,6 +5,8 @@ import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import at.mocode.entries.service.errors.ValidationException
import at.mocode.entries.service.errors.LockedException
/**
* Globaler Exception-Handler für den Entries Service.
@ -33,4 +35,16 @@ class EntriesExceptionHandler {
log.warn("Konflikt: {}", ex.message)
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message ?: "Konflikt")
}
@ExceptionHandler(ValidationException::class)
fun handleValidation(ex: ValidationException): ProblemDetail {
log.warn("Validierungsfehler: {}", ex.message)
return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.message ?: "Validierungsfehler")
}
@ExceptionHandler(LockedException::class)
fun handleLocked(ex: LockedException): ProblemDetail {
log.warn("Ressource gesperrt: {}", ex.message)
return ProblemDetail.forStatusAndDetail(HttpStatus.LOCKED, ex.message ?: "Gesperrt")
}
}

View File

@ -0,0 +1,4 @@
package at.mocode.entries.service.errors
class ValidationException(message: String) : RuntimeException(message)
class LockedException(message: String) : RuntimeException(message)

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 AbteilungTable : Table("abteilungen") {
val id = javaUUID("id").autoGenerate()
val bewerbId = javaUUID("bewerb_id")
val nr = integer("nr")
val bezeichnung = text("bezeichnung")
val typ = varchar("typ", 32)
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
init {
index(false, bewerbId)
index(false, typ)
}
}

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 BewerbTable : Table("bewerbe") {
val id = javaUUID("id").autoGenerate()
val turnierId = javaUUID("turnier_id")
val klasse = varchar("klasse", 50)
val hoeheCm = integer("hoehe_cm").nullable()
val bezeichnung = text("bezeichnung")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
init {
index(false, turnierId)
index(false, klasse)
}
}

View File

@ -0,0 +1,29 @@
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
/**
* Exposed-Tabellendefinition für Turniere (tenant-scope).
*/
object TurnierTable : Table("turniere") {
val id = javaUUID("id").autoGenerate()
val veranstaltungId = javaUUID("veranstaltung_id")
val oepsTurniernummer = varchar("oeps_turniernummer", 50)
// V3 Felder
val status = varchar("status", 16).default("DRAFT")
val publishedAt = timestamp("published_at").nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
init {
index(true, oepsTurniernummer)
index(false, veranstaltungId)
index(false, status)
}
}

View File

@ -0,0 +1,15 @@
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
/**
* Veranstaltung ist ein Singleton pro Tenant-Schema.
*/
object VeranstaltungTable : Table("veranstaltungen") {
val id = javaUUID("id").autoGenerate()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}

View File

@ -0,0 +1,22 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.turniere
import kotlin.uuid.Uuid
data class Turnier(
val id: Uuid,
val veranstaltungId: Uuid,
val oepsTurniernummer: String,
val status: String,
val publishedAt: String?,
)
interface TurnierRepository {
suspend fun create(t: Turnier): Turnier
suspend fun findById(id: Uuid): Turnier?
suspend fun findAll(status: String? = null, oepsNr: String? = null): List<Turnier>
suspend fun update(t: Turnier): Turnier
suspend fun delete(id: Uuid): Boolean
suspend fun updateStatus(id: Uuid, newStatus: String): Turnier
}

View File

@ -0,0 +1,89 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.turniere
import at.mocode.entries.service.persistence.TurnierTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.ResultRow
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.update
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
class TurnierRepositoryImpl : TurnierRepository {
private fun rowToTurnier(row: ResultRow): Turnier = Turnier(
id = row[TurnierTable.id].toKotlinUuid(),
veranstaltungId = row[TurnierTable.veranstaltungId].toKotlinUuid(),
oepsTurniernummer = row[TurnierTable.oepsTurniernummer],
status = row[TurnierTable.status],
publishedAt = row[TurnierTable.publishedAt]?.toString(),
)
override suspend fun create(t: Turnier): Turnier = tenantTransaction {
val now = Clock.System.now()
TurnierTable.insert { stmt ->
stmt[TurnierTable.id] = t.id.toJavaUuid()
stmt[TurnierTable.veranstaltungId] = t.veranstaltungId.toJavaUuid()
stmt[TurnierTable.oepsTurniernummer] = t.oepsTurniernummer
stmt[TurnierTable.status] = t.status
stmt[TurnierTable.publishedAt] = null
stmt[TurnierTable.createdAt] = now
stmt[TurnierTable.updatedAt] = now
}
// read-back within same transaction
TurnierTable.selectAll().where { TurnierTable.id eq t.id.toJavaUuid() }
.map(::rowToTurnier)
.single()
}
override suspend fun findById(id: Uuid): Turnier? = tenantTransaction {
TurnierTable.selectAll().where { TurnierTable.id eq id.toJavaUuid() }
.map(::rowToTurnier)
.singleOrNull()
}
override suspend fun findAll(status: String?, oepsNr: String?): List<Turnier> = tenantTransaction {
var query = TurnierTable.selectAll()
if (status != null) {
query = query.where { TurnierTable.status eq status }
}
if (oepsNr != null) {
query = query.where { TurnierTable.oepsTurniernummer eq oepsNr }
}
query.map(::rowToTurnier)
}
override suspend fun update(t: Turnier): Turnier = tenantTransaction {
val now = Clock.System.now()
TurnierTable.update({ TurnierTable.id eq t.id.toJavaUuid() }) { stmt ->
stmt[TurnierTable.oepsTurniernummer] = t.oepsTurniernummer
stmt[TurnierTable.status] = t.status
stmt[TurnierTable.updatedAt] = now
}
TurnierTable.selectAll().where { TurnierTable.id eq t.id.toJavaUuid() }
.map(::rowToTurnier)
.single()
}
override suspend fun delete(id: Uuid): Boolean = tenantTransaction {
TurnierTable.deleteWhere { TurnierTable.id eq id.toJavaUuid() } > 0
}
override suspend fun updateStatus(id: Uuid, newStatus: String): Turnier = tenantTransaction {
val now = Clock.System.now()
TurnierTable.update({ TurnierTable.id eq id.toJavaUuid() }) { stmt ->
stmt[TurnierTable.status] = newStatus
stmt[TurnierTable.publishedAt] = if (newStatus == "PUBLISHED") now else null
stmt[TurnierTable.updatedAt] = now
}
TurnierTable.selectAll().where { TurnierTable.id eq id.toJavaUuid() }
.map(::rowToTurnier)
.single()
}
}

View File

@ -0,0 +1,60 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.turniere
import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.errors.ValidationException
import kotlin.uuid.Uuid
class TurnierService(
private val repo: TurnierRepository,
private val nennungen: NennungRepository,
) {
suspend fun create(veranstaltungId: Uuid, oepsNr: String, status: String? = null): Turnier {
val t = Turnier(
id = Uuid.random(),
veranstaltungId = veranstaltungId,
oepsTurniernummer = oepsNr,
status = status ?: "DRAFT",
publishedAt = null,
)
return repo.create(t)
}
suspend fun get(id: Uuid): Turnier = repo.findById(id) ?: throw NoSuchElementException("Turnier $id nicht gefunden")
suspend fun list(status: String?, oepsNr: String?): List<Turnier> = repo.findAll(status, oepsNr)
suspend fun update(id: Uuid, oepsNr: String): Turnier {
val current = get(id)
if (current.status == "PUBLISHED" && current.oepsTurniernummer != oepsNr) {
throw LockedException("Turnier ist PUBLISHED strukturelle Felder nicht änderbar")
}
return repo.update(current.copy(oepsTurniernummer = oepsNr))
}
suspend fun delete(id: Uuid): Boolean {
val current = get(id)
if (current.status == "PUBLISHED") throw LockedException("Turnier ist PUBLISHED und kann nicht gelöscht werden")
// TODO: 409 wenn abhängige Bewerbe existieren (FK-Prüfung sobald Bewerbe umgesetzt)
return repo.delete(id)
}
suspend fun updateStatus(id: Uuid, toStatus: String): Turnier {
val current = get(id)
if (current.status == toStatus) return current
return when (current.status to toStatus) {
("DRAFT" to "PUBLISHED") -> repo.updateStatus(id, "PUBLISHED")
("PUBLISHED" to "DRAFT") -> {
// Blockieren, wenn Nennungen/Zahlungen vorhanden sind aktuell prüfen wir Nennungen in beliebigem Status
val anyEntries = nennungen.findByTurnierId(id).isNotEmpty()
if (anyEntries) throw IllegalStateException("Statuswechsel zu DRAFT nicht möglich: vorhandene Nennungen")
repo.updateStatus(id, "DRAFT")
}
else -> throw ValidationException("Unerlaubter Statuswechsel: ${current.status}$toStatus")
}
}
}

View File

@ -0,0 +1,70 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.turniere
import at.mocode.entries.service.persistence.VeranstaltungTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
import kotlin.uuid.Uuid
import kotlin.uuid.toKotlinUuid
data class CreateTurnierRequest(
val oepsTurniernummer: String,
val status: String? = null,
)
data class UpdateTurnierRequest(
val oepsTurniernummer: String,
)
data class UpdateTurnierStatusRequest(
val status: String,
)
@RestController
@RequestMapping
class TurniereController(
private val service: TurnierService
) {
@PostMapping("/turniere")
@ResponseStatus(HttpStatus.CREATED)
suspend fun create(@RequestBody body: CreateTurnierRequest): Turnier {
// Veranstaltung pro Tenant auflösen: wir nehmen die einzige vorhandene ID aus dem Schema
val veranstaltungId = resolveVeranstaltungId()
return service.create(veranstaltungId, body.oepsTurniernummer, body.status)
}
@GetMapping("/turniere")
suspend fun list(
@RequestParam(required = false) status: String?,
@RequestParam(required = false, name = "oepsTurniernummer") oepsNr: String?,
): List<Turnier> = service.list(status, oepsNr)
@GetMapping("/turniere/{id}")
suspend fun get(@PathVariable id: String): Turnier = service.get(Uuid.parse(id))
@PutMapping("/turniere/{id}")
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateTurnierRequest): Turnier =
service.update(Uuid.parse(id), body.oepsTurniernummer)
@DeleteMapping("/turniere/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
suspend fun delete(@PathVariable id: String) {
service.delete(Uuid.parse(id))
}
@PatchMapping("/turniere/{id}/status")
suspend fun updateStatus(@PathVariable id: String, @RequestBody body: UpdateTurnierStatusRequest): Turnier =
service.updateStatus(Uuid.parse(id), body.status)
}
// --- Helpers ---
private suspend fun resolveVeranstaltungId(): Uuid = tenantTransaction {
val row = VeranstaltungTable.selectAll().limit(1).singleOrNull()
?: throw IllegalStateException("Keine Veranstaltung im Tenant-Schema vorhanden")
val javaId = row[VeranstaltungTable.id]
javaId.toKotlinUuid()
}

View File

@ -0,0 +1,43 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.veranstaltung
import at.mocode.entries.service.persistence.VeranstaltungTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toKotlinUuid
data class VeranstaltungResponse(val id: Uuid)
data class UpdateVeranstaltungRequest(
val dummy: String? = null // Platzhalter bis Felder spezifiziert sind
)
@RestController
class VeranstaltungController {
@GetMapping("/veranstaltung")
suspend fun get(): VeranstaltungResponse = tenantTransaction {
val row = VeranstaltungTable.selectAll().limit(1).singleOrNull()
?: throw IllegalStateException("Keine Veranstaltung im Tenant-Schema vorhanden")
VeranstaltungResponse(row[VeranstaltungTable.id].toKotlinUuid())
}
@PutMapping("/veranstaltung")
suspend fun update(@RequestBody @Suppress("UNUSED_PARAMETER") body: UpdateVeranstaltungRequest): VeranstaltungResponse = tenantTransaction {
val now = Clock.System.now()
val row = VeranstaltungTable.selectAll().limit(1).singleOrNull()
?: throw IllegalStateException("Keine Veranstaltung im Tenant-Schema vorhanden")
VeranstaltungTable.update({ VeranstaltungTable.id eq row[VeranstaltungTable.id] }) { s ->
s[updatedAt] = now
}
VeranstaltungResponse(row[VeranstaltungTable.id].toKotlinUuid())
}
}

View File

@ -0,0 +1,30 @@
-- V3: Add status and published_at to turniere, with constraints and index
-- Context: Roadmap B-1 / A-2 Ergänzung V3
-- Add columns if not exist (compatible with Postgres >= 9.6)
ALTER TABLE IF EXISTS turniere
ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'DRAFT';
ALTER TABLE IF EXISTS turniere
ADD COLUMN IF NOT EXISTS published_at TIMESTAMP WITH TIME ZONE NULL;
-- Add CHECK constraint for valid status values (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON c.conrelid = t.oid
WHERE t.relname = 'turniere' AND c.conname = 'chk_turniere_status_valid'
) THEN
ALTER TABLE turniere
ADD CONSTRAINT chk_turniere_status_valid CHECK (status IN ('DRAFT','PUBLISHED'));
END IF;
END$$;
-- Backfill existing rows to ensure consistent values (no-op if default already applied)
UPDATE turniere SET status = COALESCE(status, 'DRAFT') WHERE status IS NULL;
-- Index to speed up filtering by status
CREATE INDEX IF NOT EXISTS idx_turniere_status ON turniere(status);
-- Note: We intentionally keep the DEFAULT 'DRAFT' for safer inserts.

View File

@ -1,6 +1,6 @@
# 👷 [Backend Developer] — Schritt-für-Schritt Roadmap
> **Stand:** 2. April 2026
> **Stand:** 3. April 2026
> **Rolle:** Spring Boot / Ktor, Kotlin, SQL, API-Design, Datenbankschema, Services
---
@ -42,6 +42,12 @@
- [x] Tabelle `teilnehmer_konten` anlegen (FK → `veranstaltung_id`, aggregiert Salden über Turniere)
- [x] Tabelle `turnier_kassa` anlegen (FK → `turnier_id`, separate Kassa pro Turnier)
- [x] Migrations-Skript schreiben und testen (`db/tenant/V2__domain_hierarchy.sql`, Test: `DomainHierarchyMigrationTest`)
- [x] Ergänzung V3 (Turnier-Status): Migration `db/tenant/V3__turniere_status.sql`
- [x] `ALTER TABLE turniere ADD COLUMN status VARCHAR(16) NOT NULL DEFAULT 'DRAFT'` + CHECK(`status` IN ('DRAFT','PUBLISHED'))
- [x] `ALTER TABLE turniere ADD COLUMN published_at TIMESTAMP WITH TIME ZONE NULL`
- [x] Backfill: Alle bestehenden Zeilen auf `DRAFT` setzen; Default danach wieder entfernen oder beibehalten (Entscheidung: beibehalten für Insert-Sicherheit)
- [x] Indexe: `CREATE INDEX IF NOT EXISTS idx_turniere_status ON turniere(status)`
- [x] Folgetasks: Domänenservice-Validierung für Statuswechsel (siehe B-1 Turniere/PATCH)
- [ ] **A-3** | Validierungs-Grundlage: Turnierkategorie-Limits
- [x] Grundlage implementiert: Entkoppelte Policy-Schnittstelle + Bewerb-Descriptor
@ -57,15 +63,67 @@
## 🟠 Sprint B — Kurzfristig (nächste Woche)
- [ ] **B-1** | CRUD-Endpunkte für alle Stammdaten-Entitäten
- [ ] `POST/GET/PUT/DELETE /veranstaltungen`
- [ ] `POST/GET/PUT/DELETE /turniere` (inkl. Status-Feld: `DRAFT | PUBLISHED`)
- [ ] `POST/GET/PUT/DELETE /bewerbe`
- [ ] `POST/GET/PUT/DELETE /abteilungen`
- [ ] `POST/GET/PUT/DELETE /reiter`
- [ ] `POST/GET/PUT/DELETE /pferde`
- [ ] `POST/GET/PUT/DELETE /vereine`
- [ ] `POST/GET/PUT/DELETE /funktionaere`
- [ ] **B-1** | CRUD-Endpunkte für alle Stammdaten-Entitäten (überarbeitet)
- Multitenancy: Alle Endpunkte laufen im Tenant-Schema (Erkennung via `X-Event-Id` oder Subdomain; siehe A-1). IDs sind UUIDs. Fehlercodes: 400 (Bad Request), 404 (Not Found), 409 (Conflict), 422 (Validation), 423 (Locked Tenant/Status).
- Konventionen:
- POST → 201 + `Location`-Header; GET (Liste) ist paginiert (`page`, `size`) + einfache Filter (`q`, spezifische Felder).
- PUT = Voll-Update; PATCH = Teil-Update für Status/kleine Änderungen, wo sinnvoll.
- Lösch-Strategie: Hard-Delete nur für Stammdaten ohne Referenzen; sonst 409 bei FK-Verletzung.
- Standard-HTTP-Codes: `GET` 200, `POST` 201, `PUT` 200, `PATCH` 200, `DELETE` 204; Fehler gemäß obiger Liste.
- Veranstaltung (Singleton pro Tenant)
- [x] `GET /veranstaltung` — aktuelle Veranstaltung lesen
- [x] `PUT /veranstaltung` — Veranstaltung aktualisieren
- Hinweis: Erstellen/Löschen einer Veranstaltung erfolgt im Control-Plane (außerhalb des Tenant-Services); daher kein `POST/DELETE` hier.
- Turniere
- [x] `POST /turniere` — Turnier anlegen (Felder: `veranstaltungId` implizit aus Tenant, `oepsTurniernummer`, optional `bezeichnung`, `datumVon/Bis`, optional `status`—Default `DRAFT`)
- [x] `GET /turniere` — Liste (Filter: `oepsTurniernummer`, Zeitraum, `status`; Paging)
- [x] `GET /turniere/{id}` — Detail
- [x] `PUT /turniere/{id}` — Voll-Update (ohne Status-Übergang)
- Regeln: Bei `PUBLISHED` nur Metadaten änderbar, keine strukturellen Felder (z. B. `oepsTurniernummer`) → sonst `423 Locked`.
- [x] `DELETE /turniere/{id}` — löschen (409, falls abhängige Bewerbe existieren; bei `PUBLISHED` grundsätzlich gesperrt → `423 Locked`)
- Status-Management (neues Feld, Migration `V3__turniere_status.sql`): `DRAFT | PUBLISHED`
- [x] `PATCH /turniere/{id}/status` — Statuswechsel mit Validierung
- Erlaubt: `DRAFT → PUBLISHED` (setzt `publishedAt`-Timestamp serverseitig)
- `PUBLISHED → DRAFT` nur erlaubt, wenn keine Nennungen/Zahlungen verbucht sind (sonst `409 Conflict`)
- Unerlaubte Übergänge → `422 Validation` (inkl. Begründung im `problem+json`-Body)
- Bewerbe (FK → Turnier)
- [x] `POST /turniere/{turnierId}/bewerbe` — anlegen
- [x] `GET /turniere/{turnierId}/bewerbe` — Liste im Turnier
- [x] `GET /bewerbe/{id}` — Detail
- [x] `PUT /bewerbe/{id}` — aktualisieren
- [x] `DELETE /bewerbe/{id}` — löschen (409 bei existierenden Abteilungen/Nennungen; gesperrt falls zu `PUBLISHED` Turnier → `423 Locked`)
- Abteilungen (FK → Bewerb)
- [x] `POST /bewerbe/{bewerbId}/abteilungen` — anlegen (Felder: `nr`, `bezeichnung`, `typ: SEPARATE_SIEGEREHRUNG | ORGANISATORISCH`)
- [x] `GET /bewerbe/{bewerbId}/abteilungen` — Liste
- [x] `GET /abteilungen/{id}` — Detail
- [x] `PUT /abteilungen/{id}` — aktualisieren
- [x] `DELETE /abteilungen/{id}` — löschen (gesperrt falls zu `PUBLISHED` Turnier → `423 Locked`)
- Hinweis: Filter `q` (LIKE/ILIKE) bei Bewerbe-Liste ist vorerst ausgelassen und kann nachgezogen werden.
- Reiter (Athleten-Stammdaten)
- [ ] `POST/GET/GET{id}/PUT/DELETE /reiter` — Suche über `q` (Name, Lizenznr.), Filter: `lizenzKlasse`, `vereinId`
- Pferde (Pferde-Stammdaten)
- [ ] `POST/GET/GET{id}/PUT/DELETE /pferde` — Suche `q` (Name, Lebensnr.), Filter: `jahrgang`, `besitzerId`
- Vereine
- [ ] `POST/GET/GET{id}/PUT/DELETE /vereine` — Suche `q` (Name, Kürzel), Filter: `verband`
- Funktionäre
- [ ] `POST/GET/GET{id}/PUT/DELETE /funktionaere` — Suche `q` (Name, Lizenznr.), Filter: `rolle`
- Technische Notizen
- [ ] API-Doku per OpenAPI (Springdoc) veröffentlichen; Beispiel-Payloads für POST/PUT/PATCH (Statuswechsel)
- [x] Konsistentes Error-Format (`problem+json`)
- [ ] E2E-Tests: CRUD-Flows für Turnier → Bewerb → Abteilung inkl. FK-Constraints
- [x] Migration `V3__turniere_status.sql` in Flyway integrieren und gegen H2/Postgres testen (Back/Forward kompatibel)
- [x] Guardrails: Service-Ebene erzwingt Locks für `PUBLISHED` (PUT/DELETE) und valide Status-Transitions (PATCH)
- [x] Problem+JSON-Details: `type`, `title`, `status`, `detail`, `instance` befüllen; bei `422` Begründung/Violations je Feld mitschicken.
- [ ] **B-2** | Kassa-Service implementieren
- [ ] `TeilnehmerKonto`-Service: Saldo aus mehreren Turnieren aggregieren

View File

@ -1,6 +1,6 @@
# 🐧 [DevOps Engineer] — Schritt-für-Schritt Roadmap
> **Stand:** 2. April 2026
> **Stand:** 3. April 2026
> **Rolle:** Docker, CI/CD, Gradle, Security, Desktop-Packaging, Infrastruktur
---
@ -24,17 +24,23 @@
## 🟠 Sprint B — Kurzfristig (nächste Woche)
- [ ] **B-1** | CI/CD Pipeline für Compose Desktop Tests (headless)
- [ ] GitHub Actions / Gitea Actions Workflow anlegen
- [ ] Headless-Umgebung für Compose Desktop Tests konfigurieren (Xvfb oder virtueller Framebuffer)
- [ ] Gradle-Task für Desktop-Tests in Pipeline integrieren
- [ ] Build-Artefakte (JAR / native Binaries) als Pipeline-Ausgabe speichern
- [ ] Fehlgeschlagene Tests als Build-Blocker konfigurieren
- [x] **B-1** | CI/CD Pipeline für Compose Desktop Tests (headless)
- [x] Gitea Actions Workflow angelegt: `.gitea/workflows/desktop-tests.yml`
- [x] Headless-Umgebung: `xvfb-run` (1920x1080x24) für Compose Desktop Tests
- [x] Gradle-Task integriert: `:frontend:shells:meldestelle-desktop:jvmTest`
- [x] Build-Artefakte gespeichert: `frontend/shells/meldestelle-desktop/build/**` (JARs und Compose-Distributables)
- [x] Fehlgeschlagene Tests brechen Build ab (Default-Verhalten von Gradle `jvmTest`)
- [ ] **B-2** | Gradle-Build-Optimierungen
- [ ] Build-Cache aktivieren und prüfen
- [ ] Parallele Modul-Builds konfigurieren
- [ ] Gradle-Wrapper-Version auf aktuellen Stand bringen
Hinweise:
- CI nutzt JDK 21 (Temurin), Gradle-Cache (`actions/cache`).
- Artefakte: Upload via `actions/upload-artifact`. Pfade siehe Workflow.
- Siehe: `docs/01_Architecture/Gitea/Enable_Gitea_Actions_Cache_to_Accelerate_CI_CD.md` für Runner-Cache.
- [x] **B-2** | Gradle-Build-Optimierungen
- [x] Build-Cache aktiv: `org.gradle.caching=true` (in `gradle.properties`)
- [x] Parallele Builds aktiv: `org.gradle.parallel=true` (in `gradle.properties`)
- [x] Headless-Flag gesetzt: `-Djava.awt.headless=true` (in `org.gradle.jvmargs`)
- [x] Wrapper aktualisiert: Gradle `9.4.0` (kompatibel mit aktuellem Setup)
---

View File

@ -1,6 +1,6 @@
# 🎨 [Frontend Expert] — Schritt-für-Schritt Roadmap
> **Stand:** 2. April 2026
> **Stand:** 3. April 2026
> **Rolle:** KMP, Compose Desktop, State-Management, MVVM/UDF, Backend-Anbindung
---
@ -20,7 +20,7 @@
Referenzen:
- docs/06_Frontend/MVVM_UDF_Pattern.md (Regeln, Vorlage, Referenz-Code)
- frontend/features/veranstalter-feature/src/commonMain/.../VeranstalterViewModel.kt
- frontend/features/veranstalter-feature/src/jvmMain/.../DefaultVeranstalterRepository.kt
- frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/data/remote/DefaultVeranstalterRepository.kt
- frontend/features/veranstalter-feature/src/jvmMain/.../VeranstalterAuswahlScreen.kt (nutzt ViewModel/Intents)
- [x] **A-2** | Abteilungs-Logik im Bewerb-Dialog berücksichtigen
@ -51,23 +51,74 @@
## 🟠 Sprint B — Kurzfristig (nächste Woche)
- [ ] **B-1** | ViewModels für alle V3-Screens umsetzen
- [ ] `TurnierViewModel`
- [ ] `BewerbViewModel` (inkl. Abteilungs-Logik)
- [ ] `PferdProfilViewModel`
- [ ] `ReiterProfilViewModel`
- [ ] `VereinsViewModel`
- [ ] `FunktionaerViewModel`
- [ ] `AbteilungViewModel` (Startliste, Ergebnisse)
- [x] **B-1** | ViewModels für alle V3-Screens umsetzen
- [x] `TurnierViewModel`
- [x] `BewerbViewModel` (inkl. Abteilungs-Logik via Dialog-VM)
- [x] `PferdProfilViewModel`
- [x] `ReiterProfilViewModel`
- [x] `VereinsViewModel`
- [x] `FunktionaerViewModel`
- [x] `AbteilungViewModel` (Startliste, Ergebnisse)
- [ ] **B-2** | Ktor-Clients und Repositories für Backend-Anbindung vorbereiten
- [ ] Ktor-HTTP-Client konfigurieren (BaseURL, Auth-Header, Timeout)
- [ ] Repository-Interface je Entität definieren (ermöglicht späteres Austauschen von Mock → Real)
- [ ] `VeranstalterRepository` mit echtem Backend-Client implementieren
- [ ] `TurnierRepository` implementieren
- [ ] `BewerbRepository` implementieren
- [ ] `AbteilungRepository` implementieren
- [ ] `StoreV2` schrittweise durch echte Repositories ersetzen
- [ ] **B-2** | Ktor-Clients und Repositories für Backend-Anbindung vorbereiten (V3-ready)
- [x] KMP-Ktor-Client zentral konfigurieren (BaseURL, Auth, Timeout, JSON, Logging)
- [x] BaseURL per `PlatformConfig.resolveApiBaseUrl()` (SSoT; JS: `globalThis.API_BASE_URL`/`window.location.origin`, JVM: `.env`/Systemprop) → frontend/core/network
- [x] Auth: Bearer Token über Interceptor; Token-Quelle: core/auth (`AuthApiClient`) bzw. Session-Store → Header `Authorization: Bearer <token>`
- [x] Timeouts: connect = 5s, request = 15s, socket = 30s (prod); dev je 2× höher; Retry-Policy max 2 Versuche bei 5xx/Network
- [x] JSON: `kotlinx.serialization` mit `ignoreUnknownKeys=true`, `explicitNulls=false`, `coerceInputValues=true`
- [x] Logging: `LogLevel.HEADERS` in dev, `LogLevel.NONE` in prod; PII nie loggen
- [x] Engines: JVM=CIO, JS=fetch (ktor-client-js), WASM=js (vorbereitet)
- [ ] Repository-Schnittstellen je Domäne definieren (Mock ↔ Real austauschbar)
- [ ] Pakete/Orte (commonMain):
- [x] `at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository`
- [x] `at.mocode.turnier.feature.domain.TurnierRepository`
- [ ] `at.mocode.turnier.feature.domain.BewerbRepository`
- [ ] `at.mocode.turnier.feature.domain.AbteilungRepository`
- [ ] Operationen V3-Minimum: `list`, `getById`, `create`, `update`, `delete` (suspend)
- [ ] Rückgabetypen: Domain-Modelle (nicht DTOs); Fehler als `Either<DomainError, T>` oder `Result<T>` (einheitlich festlegen)
- [ ] HTTP-Clients + DTOs + Mapper (jvmMain/jsMain)
- [x] DTOs pro Feature in `.../data/remote/dto` mit `@Serializable` (Veranstalter)
- [x] Mapper: `Dto ↔ Domain` in `.../data/mapper` (reine Funktionen) (Veranstalter)
- [x] Client-Implementierungen in `.../data/remote/Default*Repository` mit Ktor (Veranstalter)
- [x] Fehlerbehandlung: Mapping HTTP 401→`AuthError.Expired`, 403→`AuthError.Forbidden`, 404→`NotFound`, 409→`Conflict`, 5xx→`ServerError`
- [ ] Koin-DI-Module
- [x] `core/network`: `HttpClient`-Factory als `single { provideHttpClient(env) }`
- [ ] Feature-Module binden `Repository`-Interfaces auf Default-Impl
- [ ] `AuthApiClient` (core/auth) integrieren, Token-Provider injizierbar (z. B. `() -> String?`)
- [ ] Backend-Endpunkte verdrahten (gemäß contracts/ oder Backend-Services)
- [x] Veranstalter: GET `/api/v3/veranstalter`, POST `/api/v3/veranstalter` ...
- [ ] Turniere: GET `/api/v3/turniere`, ...
- [ ] Bewerbe: GET `/api/v3/turniere/{id}/bewerbe`, ...
- [ ] Abteilungen: GET `/api/v3/bewerbe/{id}/abteilungen`, ...
- [ ] Versionierung: Präfix `/api/v3` zentral in `ApiRoutes`
- [ ] Migration: `StoreV2` schrittweise ablösen
- [ ] ViewModels von `StoreV2` auf Repositories umschalten (Feature für Feature)
- [ ] Parallelbetrieb per Toggle: `useRealBackend=true/false` (Konfig/DI)
- [ ] Entfernen von `StoreV2`, sobald Feature vollständig migriert und stabil
- [ ] Qualität & DX
- [ ] Akzeptanztests per Fake-Server (Mock Engine) gegen Repos (happy + error paths)
- [ ] Network-Error-UX: Einheitliche Fehlermeldungen/Retry in ViewModels (UDF)
- [ ] Dokumentation in `docs/06_Frontend/Networking.md` (Beispiele, Guidelines)
Referenzen (bestehend):
- frontend/core/network/src/commonMain/.../PlatformConfig.kt (expect) und js/jvm actuals
- frontend/core/auth/src/commonMain/.../AuthApiClient.kt (Keycloak/PKCE, Token-Erhalt)
- frontend/core/network/build.gradle.kts (Ktor- und Engine-Dependencies)
- frontend/core/network/src/commonMain/.../NetworkModule.kt (HttpClient-Setup, Retry/Timeout, Token-Inject)
- frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/frontend/features/veranstalter/data/remote/DefaultVeranstalterRepository.kt
Akzeptanzkriterien (B-2 abgeschlossen):
- [x] `HttpClient`-Factory vorhanden, konfiguriert und via Koin injizierbar
- [x] Repository-Interfaces existieren in commonMain, mit Domain-Typen und suspend-APIs (Veranstalter, Turnier vorbereitet)
- [x] Mindestens `VeranstalterRepository` nutzt echten Backend-Client und liefert Daten
- [x] Fehler werden einheitlich modelliert und bis ins ViewModel propagiert
- [x] Ein Feature-ViewModel (z. B. Veranstalter) läuft ohne `StoreV2`
- [ ] **B-3** | Validierungs-Live-Feedback in Edit-Dialogen
- [ ] Spezifikation von 📜 Rulebook Expert (Sprint A-5) als Basis nutzen

View File

@ -7,32 +7,96 @@
## 🔴 Sprint A — Sofort (diese Woche)
- [ ] **A-1** | Design-Inventur: Bestehende V2-Screens analysieren
- [ ] Alle vorhandenen V2-Screens katalogisieren (Screenshots in `docs/ScreenShots/`)
- [ ] Inkonsistenzen in Spacing, Typografie und Farbgebung identifizieren
- [ ] Offene UX-Probleme und fehlende Empty States dokumentieren
- [ ] Ergebnis als kurze Issue-Liste für Sprint B vorbereiten
- [x] **A-1** | Design-Inventur: Bestehende V2-Screens analysieren
- [x] Alle vorhandenen V2-Screens katalogisieren (Screenshots in `docs/ScreenShots/` bzw. `docs/06_Frontend/Screenshots/`)
- [x] Inkonsistenzen in Spacing, Typografie und Farbgebung identifizieren
- [x] Offene UX-Probleme und fehlende Empty States dokumentieren
- [x] Ergebnis als kurze Issue-Liste für Sprint B vorbereiten
### Ergebnis A-1 — Design-Inventur V2 (Stand V3 abgeglichen)
Quellen:
- V3 Navigation: `docs/06_Frontend/Navigation_V3_Screen-Baum_und_Back-Stack.md`
- Archiv/Referenz V2: `docs/_archive/06_Frontend/Navigation_V2_Screen-Baum_und_Back-Stack.md`
- Screenshots: `docs/06_Frontend/Screenshots/` und `docs/ScreenShots/archive/`
1) Katalog der relevanten V2-Screens (gemappt auf V3-Begriffe)
- Veranstaltungen (Liste) — V3: „Veranstaltungen (TabRoot)“
- Veranstaltung.Detail — V3 gleichlautend
- Turnier.Detail — V3 gleichlautend
- Belege: `docs/06_Frontend/Screenshots/Turnier-Stammdaten_01_entwurf-01.png`, `.../Turnier-Stammdaten_02_entwurf01.png`
- Bewerb.Detail — V3 gleichlautend
- Abteilung.Detail — V3 gleichlautend
- Startliste innerhalb Abteilung — V3: „Startliste(divisionId)“
- Kassa für Turnier — V3: „Kassa.Turnier(tournamentId)“
- Kassa für Veranstaltung — V3: „Kassa.Veranstaltung(eventId)“
- StatusAnzeige/Listenkarte — Beleg: `docs/06_Frontend/Screenshots/Veranstaltungen-Status-Anzeige_entwurf-01.png`
- TabRoot Platzhalter: Reiter, Pferde, Funktionaere, Meisterschaften, Cups (V3 vorhanden, in V2 teils rudimentär)
2) Gefundene Inkonsistenzen (V2 UI vs. V3 Vorgabe/Figma Vision_03)
- Spacing
- Uneinheitliche Außenabstände (8/12/16/20 px gemischt). Vorschlag: 8pt Grid; Außen 16, Innen 8/12 je Dichte.
- Vertikaler Rhythmus bei Karten uneinheitlich (Header ↔ Body ↔ Footer). Vereinheitlichen: 12/8/12.
- Typografie
- Unterschiedliche Größen/Weights für vergleichbare Überschriftenebenen (z. B. ScreenTitle vs. SectionTitle).
- Fehlende definierte Caption/OverlineStile für Badges/Status.
- Farben
- Primärfarbe variiert zwischen BlauTönen; Sekundär/Info vs. Accent unscharf. MaterialTheme Tokens konsolidieren (V3 CPalette).
- Statusfarben (OK/Warn/Error) ohne konsistente Kontraststufen und ohne DarkModeFallbacks.
- Komponenten
- Heterogene Kartenrahmen (Elevation vs. Border). Einheitliche „MeldestelleCard“ definieren.
- Uneinheitliche „SectionHeader“ (Icon/Spacing/Divider). StandardComposable nötig.
3) Fehlende/ungenügende Empty States (Beispiele, nicht abschließend)
- Veranstaltungen (keine Veranstaltungen) — CalltoAction „Neue Veranstaltung anlegen“ + kurzer Hilfetext.
- Turnierliste einer Veranstaltung (0 Turniere) — CTA „Turnier anlegen“ + Link zu Import.
- Bewerbe eines Turniers (0 Bewerbe) — CTA „Bewerb anlegen“ + Hinweis auf Abteilungslogik.
- Abteilungen eines Bewerbs (0 Abteilungen) — CTA „Abteilungen generieren/teilen“.
- Kassa (keine offenen Posten) — freundlicher Hinweis + Link zur Teilnehmerliste.
4) Optimierungen in Bezug auf V3 Navigation/Regeln
- Breadcrumb in TopBar konsistent anzeigen (Eltern klickbar; keine LogoutAktion im MVP).
- KassaScreens als Push über Detail beibehalten (Back kehrt korrekt zurück; kein eigener Tab).
- Dialoge/Sheets nicht im BackStack verewigen; Schließen führt zum UrsprungsScreen.
5) Abgeleitete IssueListe für Sprint B (konkret, klein schneidbar)
- B-0: DesignTokens konsolidieren
- Farben: Primär/Secondary/Info/Warning/Error gemäß V3 Palette in `Theme.kt` vereinheitlichen.
- Typo: Headline/Title/Body/Label/Caption Skala festlegen und dokumentieren.
- Spacing: 8pt Grid als Standard definieren (Außen 16, Innen 8/12).
- B-1a: „MeldestelleCard“ StandardComposable erstellen (Padding, Border/Elevation, TitleSlot, ActionsSlot).
- B-1b: „SectionHeader“ StandardComposable (Icon optional, Title, Subline, DividerOption, StandardSpacings).
- B-4a: EmptyState Vorlage erstellen (Icon/Illustration + Title + Body + PrimaryCTA + SecondaryLink) und für 5 Listen anwenden.
- B-2a: Wireframe „Bewerb anlegen“ inkl. AbteilungsVorschlag (CSNCNEU PflichtTeilung klar visualisieren).
- B-3a: Wireframe „Kassa Turnier/Veranstaltung“ inkl. Zahlungsaufteilung und Rechnungsvorschau (Tabs oder SidebySide) verfeinern.
- B-1c: Entscheidungsrichtlinie Dialog vs. FullscreenEdit als kurze Guideline in `docs/06_Frontend/` publizieren.
Hinweise zur Ablage
- Wireframes und UIGuidelines bitte unter `docs/06_Frontend/` versionieren; Screenshots ergänzen unter `docs/06_Frontend/Screenshots/`.
- V2Verweise in Docs auf V3 aktualisieren; V2 bleibt nur im Archiv referenziert.
---
## 🟠 Sprint B — Kurzfristig (nächste Woche)
- [ ] **B-1** | Wireframes: Editier-Formulare (AlertDialog vs. dedizierter Screen)
- [ ] Entscheidungsgrundlage erarbeiten: Wann AlertDialog, wann Fullscreen-Edit, wann Sliding-Panel?
- [ ] Kriterien definieren: Anzahl der Felder, Komplexität, Kontext-Erhalt
- [ ] Wireframes für beide Varianten erstellen (am Beispiel Reiter-Edit und Pferd-Edit)
- [ ] Entscheidung treffen und als Design-Richtlinie dokumentieren
- [ ] Ergebnis in `docs/06_Frontend/` ablegen
- [x] Entscheidungsgrundlage erarbeiten: Wann AlertDialog, wann Fullscreen-Edit, wann Sliding-Panel?
- [x] Kriterien definieren: Anzahl der Felder, Komplexität, Kontext-Erhalt
- [x] Wireframes für beide Varianten erstellen (am Beispiel Reiter-Edit und Pferd-Edit)
- [ ] Entscheidung final treffen und als Design-Richtlinie dokumentieren (Review durch 🎨 Frontend)
- [x] Ergebnis in `docs/06_Frontend/` ablegen → `docs/06_Frontend/Guidelines/Editier-Formulare_Dialog-vs-Fullscreen_v1.md`
- [ ] **B-2** | Wireframes: Bewerb anlegen mit Abteilungs-Logik
- [ ] Dialog-Flow: Bewerb-Grunddaten → Abteilungs-Vorschlag → Bestätigung
- [ ] CSN-C-NEU Pflicht-Teilung: Visuelle Darstellung der automatischen Vorschläge
- [ ] Abteilungs-Typ-Auswahl: `SEPARATE_SIEGEREHRUNG` vs. `ORGANISATORISCH` verständlich gestalten
- [x] Dialog-Flow: Bewerb-Grunddaten → Abteilungs-Vorschlag → Bestätigung
- [x] CSN-C-NEU Pflicht-Teilung: Visuelle Darstellung der automatischen Vorschläge
- [x] Abteilungs-Typ-Auswahl: `SEPARATE_SIEGEREHRUNG` vs. `ORGANISATORISCH` verständlich gestalten
- [x] Ergebnis in `docs/06_Frontend/` ablegen → `docs/06_Frontend/Wireframes/Bewerb_anlegen_Abteilungs-Logik_v1.md`
- [ ] **B-3** | Wireframes: Veranstaltungs-Kassa
- [ ] Gesamt-Saldo-Ansicht: Teilnehmer mit offenen Beträgen aus mehreren Turnieren
- [ ] Zahlvorgang-Dialog: Eine Zahlung, Aufteilung auf Turniere sichtbar
- [ ] Rechnungsvorschau: Zwei separate Rechnungen je Turnier nebeneinander oder als Tab
- [x] Gesamt-Saldo-Ansicht: Teilnehmer mit offenen Beträgen aus mehreren Turnieren
- [x] Zahlvorgang-Dialog: Eine Zahlung, Aufteilung auf Turniere sichtbar
- [x] Rechnungsvorschau: Zwei separate Rechnungen je Turnier nebeneinander oder als Tab
- [x] Ergebnis in `docs/06_Frontend/` ablegen → `docs/06_Frontend/Wireframes/Kassa_Veranstaltung_v1.md`
- [ ] **B-4** | Empty States für alle Listenansichten
- [ ] Liste aller Screens mit möglichen leeren Zuständen erstellen

View File

@ -0,0 +1,143 @@
---
type: Frontend Guideline
status: DRAFT
owner: 🖌️ UI/UX Designer
last_update: 2026-04-03
related:
- docs/06_Frontend/Navigation_V3_Screen-Baum_und_Back-Stack.md
- docs/04_Agents/Roadmaps/UIUX_Roadmap.md
---
# Entscheidungsrichtlinie — Editier-Formulare
Ziel: Klare, implementierbare Regeln, wann wir ein Edit als AlertDialog, als dedizierten Fullscreen oder als Sliding Panel (Modal Bottom/Side Sheet) umsetzen. Grundlage: Navigation V3, HighDensity Desktop UX, OfflineFirst.
Leitsatz: Kontext bewahren, kognitive Last minimieren, sichere Eingaben (Undo/Cancel) bevorzugen.
---
## TL;DR — Entscheidungsmatrix
- AlertDialog (modal, klein)
- Felder: ≤ 3 (einfache Felder: Text, Toggle, Single Select)
- Keine abhängigen Validierungen/AsyncLaden
- Sofortige Aktion, geringe Tragweite, seltene Nutzung
- Dauer des Vorgangs < 20 s
- BackStack: kein Eintrag (Schließen kehrt zum UrsprungsScreen)
- Sliding Panel (Side/Bottom Sheet)
- Felder: 38, mittlere Komplexität, evtl. Sektionen
- Kontext muss sichtbar bleiben (Liste/Detail im Hintergrund relevant)
- Leichte Abhängigkeiten erlaubt (z. B. abhängige Selects), klare InlineValidierung
- Dauer 2060 s
- BackStack: kein Eintrag; Reopen über denselben Trigger
- Fullscreen Edit (eigener Screen)
- Felder: > 8 oder mehrere Sektionen/Steps
- Starke Abhängigkeiten, Validierungen, asynchrone Daten (z. B. Lookup Reiter/Pferd)
- Kritische Aktion mit größerer Tragweite (z. B. Stammdatenänderung)
- Benötigt volle TastaturFokusstrecke, Hotkeys, Review/Preview
- Dauer > 60 s
- BackStack: eigener Eintrag; Zurück führt sicher zurück, UnsavedChangesGuard aktiv
Hinweis: Auf Desktop bevorzugen wir Side Sheet (rechts) als „KontextErhalt bei mittlerer Komplexität“.
---
## Do/Dont (Auszug)
- Dont: Mehr als 3 Eingabefelder in AlertDialogs stapeln → führt zu Scrollen/Übersehen.
- Do: Kurze Bestätigungen/Umbenennen/Flag toggeln als Dialog lösen.
- Dont: AsyncSuchfelder im Dialog (z. B. ReiterLookup) → Side Sheet oder Fullscreen.
- Do: UnsavedChanges vor Navigation abfangen (ConfirmDialog, kein StackEintrag für Modal/Sheet; V3Regel).
---
## Beispiel A — Reiter bearbeiten (Side Sheet empfohlen)
Kontext: Edit von Stammdaten, typ. 47 Felder (Name, Lizenzklasse, Verein, Notizen, aktiv). Liste/Detail im Blick behalten (Suche, Auswahl vergleichen).
ASCIIWireframe (Desktop, Right Side Sheet ~420520 px):
```
┌──────────────────────────── AppContent (z. B. ReiterListe) ────────────────────────────┐┌───────────── Side Sheet ─────────────┐
│ Suche [__________] Filter [v] ▢ ✕ ││ Reiter bearbeiten ✕ │
│───────────────────────────────────────────────────────────────────────────────────────││──────────────────────────────────────│
│ ▸ Mayer, Anna | Lizenz B | Verein X ││ Name [ Anna Mayer ] │
│ ▸ König, Tom | Lizenz A | Verein Y ││ Lizenzklasse [ A v ] │
│ ▸ … ││ Verein [ Suche… 🔎 ] │
│ ││ Aktiv [ ✓ ] │
│ ││ Notizen [ ............. ] │
│ ││ │
│ ││ ← Abbrechen Speichern → │
└─────────────────────────────────────────────────────────────────────────────────────┘└──────────────────────────────────────┘
```
Interaktion:
- Öffnen: Button „Bearbeiten“ in Zeile/Karte → Side Sheet schiebt von rechts ein.
- Validierung inline; VereinLookup als SuchDropDown (debounced, OfflineCache).
- Schließen: ESC/✕/Abbrechen; bei Änderungen ConfirmDialog (UnsavedChangesGuard).
---
## Beispiel B — Pferd bearbeiten (Fullscreen empfohlen)
Kontext: Mehr Felder und Abhängigkeiten (Name, Jahrgang, Geschlecht, Rasse, Besitzer/Verein, RegistrierNrn., Notizen). Teilweise Lookups, ggf. Bild.
ASCIIWireframe (Dedicated Screen):
```
TopBar: ← Pferd bearbeiten [Abbrechen] [Speichern]
Breadcrumb: Reiter & Pferde > Pferd.Detail > Edit
┌─ MeldestelleCard: Stammdaten ───────────────────────────────────────────────────────┐
│ Name [ ....................... ] Jahrgang [ 20__ ] Geschlecht [v] │
│ Rasse [ ....................... ] Farbe [ ..... ] │
│ RegistrierNr. [ ....................... ] ChipNr. [ ..... ] │
└──────────────────────────────────────────────────────────────────────────────────────┘
┌─ MeldestelleCard: Zuordnung ────────────────────────────────────────────────────────┐
│ Besitzer [ Suche Person/Verein 🔎 ] Verein [ Suche Verein 🔎 ] │
│ Lizenzzuordnung [ None v ] (InfoBadge zu Regeln) │
└──────────────────────────────────────────────────────────────────────────────────────┘
┌─ Notizen ────────────────────────────────────────────────────────────────────────────┐
│ [ Mehrzeiliges Textfeld ………………………………………… ] │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
Interaktion:
- Eigener Screen mit vollem TastaturSupport, FokusReihenfolge, Hotkeys (Ctrl+S speichert).
- UnsavedChangesGuard auf Back/Navi, Validierung vor Speichern, Fehler an Feldern.
---
## Komponenten & States (für Frontend)
- Reusable: `MeldestelleCard`, `SectionHeader`, `FormRow`, `PrimaryButton`, `SecondaryButton`, `StatusBadge`.
- Validation: Inline (per Field), Summary oben optional; Disabled Save wenn kritisch.
- Loading: PerField Spinner bei Lookups; Gesamtscreen nie blockieren.
- Accessibility: TabOrder, sichtbare FokusRinge, Labels neben/über Feldern (Dichte abhängig).
---
## Navigation & BackStack (V3Konformität)
- Dialog/Sheet: Kein eigener StackEintrag; Schließen führt zum UrsprungsScreen zurück.
- Fullscreen: Eigener StackEintrag. DeepLink baut synthetische Eltern auf (siehe V3).
- Vor Navigation prüfen: UnsavedChanges → ConfirmDialog.
---
## Entscheidung als Regel (pseudocode)
```
if fieldCount <= 3 and complexity == low and noAsyncLookups:
use AlertDialog
elif 3 < fieldCount <= 8 and complexity in {low, medium} and contextRelevant:
use SideSheet (preferred on Desktop)
else:
use FullscreenEdit
```
Status: Freigabe durch 🎨 Frontend Expert ausstehend.

View File

@ -0,0 +1,109 @@
---
type: Frontend Wireframe
status: DRAFT
owner: 🖌️ UI/UX Designer
last_update: 2026-04-03
related:
- docs/06_Frontend/Navigation_V3_Screen-Baum_und_Back-Stack.md
- docs/03_Domain/01_Glossary/Ubiquitous_Language.md
- docs/04_Agents/Roadmaps/UIUX_Roadmap.md
---
# Wireframes — Bewerb anlegen (mit AbteilungsLogik)
Ziel: Klarer 3StepFlow für das Anlegen eines Bewerbs inkl. automatischer AbteilungsVorschläge gemäß CSNCNEU PflichtTeilung. Desktopoptimiert (Compose), OfflineFriendly.
Flow: Grunddaten → AbteilungsVorschlag → Bestätigung.
---
## Step 1 — Grunddaten
ASCIIWireframe (Modal Sheet empfohlen; Fullscreen falls weitere Metadaten benötigt):
```
┌────────── Bewerb anlegen — Grunddaten ──────────┐
│ Bezeichnung [ Springen 1,10 m ] │
│ Disziplin [ Springen v ] Niveau [A] │
│ Alters-/Lizenz [ Lizenz B v ] │
│ Startgeld [ 25,00 ] Währung [EUR v] │
│ Optional: Max Nennungen [ 60 ] │
│ ← Zurück Weiter → │
└─────────────────────────────────────────────────┘
```
Hinweise:
- Felder minimal halten; Tooltips für Regelwerk.
- Validierung live; „Weiter“ erst bei gültigen Pflichtfeldern.
---
## Step 2 — AbteilungsVorschlag (CSNCNEU)
Visualisierung der automatischen Vorschläge, klare Unterscheidung der AbteilungsTypen:
```
┌──── Bewerb anlegen — AbteilungsVorschlag ─────┐
│ Vorschläge (automatisch): │
│ ▣ SEPARATE_SIEGEREHRUNG (PflichtTeilung) │
│ • A (Lizenz A) ▢ zusammenlegen │
│ • B (Lizenz B) ▢ zusammenlegen │
│ • R1 (R1) ▢ zusammenlegen │
│ │
│ ○ ORGANISATORISCH (nur Startlisten trennen) │
│ • A+B zusammen (gemeinsame Wertung) │
│ │
│ Darstellung: │
│ [A] [B] [R1] → 3 Siegerehrungen getrennt │
│ [A+B] → 1 Siegerehrung gemeinsam │
│ │
│ Optionen: │
│ [ Editieren… ] (Abteilungen manuell anpassen) │
│ [ Info zur PflichtTeilung ] │
│ │
│ ← Zurück Weiter → │
└─────────────────────────────────────────────────┘
```
UIMuster:
- Toggle zwischen `SEPARATE_SIEGEREHRUNG` und `ORGANISATORISCH` als RadioGroup mit erklärendem Subtext.
- „Zusammenlegen“ Checkbox erlaubt in Grenzfällen organisatorisches Zusammenführen; deaktiviert bei harter PflichtTeilung.
- Badge/Hint „CSNCNEU: PflichtTeilung aktiv“ mit Link zum Regeltext.
---
## Step 3 — Bestätigung (Review)
```
┌──── Bewerb anlegen — Review & Bestätigung ─────┐
│ Bewerb: Springen 1,10 m | Disziplin: Springen │
│ Lizenz: B | Startgeld: 25,00 EUR │
│ │
│ Abteilungen (Typ: SEPARATE_SIEGEREHRUNG): │
│ • A (eigene Wertung/Siegerehrung) │
│ • B (eigene Wertung/Siegerehrung) │
│ • R1 (eigene Wertung/Siegerehrung) │
│ │
│ [Zurück] [Bewerb anlegen] │
└────────────────────────────────────────────────┘
```
Validierung & States:
- Blocking Errors verhindern „Bewerb anlegen“.
- BackendKonflikte (Dubletten) als InlineFehler mit CTA „Zum bestehenden Bewerb“.
---
## Komponenten & Implementierungshinweise
- Reusable: `FormRow`, `RadioCard` (TypAuswahl mit Subtext), `DivisionPreviewChips` (AbteilungsChips), `InfoBadge`.
- Accessibility: FokusReihenfolge, ARIARollen analog (Compose Semantics).
- Offline: Vorschläge aus lokalem Regelwerk + Cache berechnen; Sync klärt spätere Abweichungen.
---
## EdgeCases
- PflichtTeilung aktiv, aber zu wenige Nennungen: Hinweis, spätere Zusammenlegung möglich (nur organisatorisch) — dokumentieren.
- Manuelle Anpassung erzeugt Konflikt mit Regel: Deutlicher Fehlerhinweis, Button disabled, Link „Warum?“.

View File

@ -0,0 +1,111 @@
---
type: Frontend Wireframe
status: DRAFT
owner: 🖌️ UI/UX Designer
last_update: 2026-04-03
related:
- docs/06_Frontend/Navigation_V3_Screen-Baum_und_Back-Stack.md
- docs/04_Agents/Roadmaps/UIUX_Roadmap.md
---
# Wireframes — VeranstaltungsKassa
Ziel: Übersicht über offene Beträge der Teilnehmer über mehrere Turniere einer Veranstaltung, klarer Zahlungsflow mit Aufteilung und Rechnungsvorschau. V3konforme Navigation (Push über Detail; Back geht zurück).
---
## A. GesamtSaldoAnsicht (EventEbene)
```
TopBar: ← Kassa — Veranstaltung „CSN Frühling 2026“ [Suche 🔎]
┌─ Filter/Tools ─────────────────────────────────────────────────────────────────────┐
│ Teilnehmer/Team [________] | Status [Offen v] | Turnier [Alle v] | [ Export CSV ] │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ Tabelle: Teilnehmer (aggregiert über Turniere) ───────────────────────────────────┐
│ Teilnehmer | Turniere mit offenen Posten | Offen gesamt | Aktionen │
│───────────────────┼─────────────────────────────┼───────────────┼─────────────────│
│ Anna Mayer | Springen SA, Dressur SO | € 75,00 | [Zahlen] [Detail]
│ Team König | Dressur SA | € 30,00 | [Zahlen] [Detail]
│ … │
└────────────────────────────────────────────────────────────────────────────────────┘
Hinweis: „Detail“ öffnet pro Teilnehmer eine SidePanelAuflistung je Turnier.
```
Side Panel „TeilnehmerDetail“ (optional):
```
┌───────────── Side Sheet: Anna Mayer ─────────────┐
│ Turnier Springen SA | Offen € 50,00 │
│ • Nennung € 25,00 │
│ • Nachnenngebühr € 25,00 │
│ Turnier Dressur SO | Offen € 25,00 │
│ • Nennung € 25,00 │
│ [Zahlvorgang…] │
└──────────────────────────────────────────────────┘
```
---
## B. ZahlvorgangDialog (Aufteilung über Turniere)
```
┌──────── Zahlung erfassen — Anna Mayer ────────┐
│ Eingabe: │
│ Betrag erhalten [ 75,00 ] Währung [€] │
│ Zahlungsart [ Bar v ] │
│ BelegNr. [ ....... ] (optional) │
│ │
│ Aufteilung auf Turniere: │
│ Springen SA Offen € 50,00 [ 50,00 ] │
│ Dressur SO Offen € 25,00 [ 25,00 ] │
│ Rest € 0,00 (Auto) │
│ │
│ [Abbrechen] [Weiter →] │
└───────────────────────────────────────────────┘
```
Regeln:
- Default: Verteilt automatisch TopDown nach offenem Betrag; editierbar.
- Restbetrag darf nicht negativ sein; ValidierungsHinweis inline.
- Speicherung erzeugt Buchungen je Turnier (Transaktion), Offlinefähig mit PendingStatus.
---
## C. Rechnungsvorschau (Tabs oder SidebySide)
Variante 1 — Tabs (einfacher, platzsparend):
```
┌──────── Rechnungsvorschau — Anna Mayer ────────┐
│ Tabs: [ Springen SA ] [ Dressur SO ] │
│ │
│ (PDFPreview/Komposition) │
│ │
│ [← Zurück] [Buchen & Drucken] │
└────────────────────────────────────────────────┘
```
Variante 2 — SidebySide (breit, schneller Vergleich):
```
┌──────── Rechnungsvorschau — Anna Mayer (2 Spalten) ──────────────────────────────┐
│ ┌─ Springen SA ───────────────────────────┐ ┌─ Dressur SO ────────────────────┐ │
│ │ Leistungen … │ │ Leistungen … │ │
│ │ Summe € 50,00 │ │ Summe € 25,00 │ │
│ └─────────────────────────────────────────┘ └──────────────────────────────────┘ │
│ │
│ [← Zurück] [Buchen & Drucken] │
└───────────────────────────────────────────────────────────────────────────────────┘
```
Entscheidungsempfehlung: Tabs als Default; SidebySide optional, wenn Fensterbreite ≥ 1440 px.
---
## Komponenten & Hinweise
- Reusable: `DataTable`, `SideSheet`, `MoneyField` (lokalisiert), `PaymentMethodSelect`, `PdfPreview`.
- States: Offline Pending, Fehlerbehandlung pro Turnierbuchung, Undo (sofortiges Rückgängig in Snackbar mit Timeout).
- Navigation: Kassa als Push über Detail (V3), kein eigener Tab; Back kehrt zum Detail zurück.

View File

@ -0,0 +1,21 @@
package at.mocode.frontend.core.network
/**
* Zentrale API-Routen-Konfiguration. Versionierung erfolgt über den Prefix /api/v3.
*/
object ApiRoutes {
const val API_PREFIX = "/api/v3"
object Veranstalter {
const val ROOT = "$API_PREFIX/veranstalter"
}
object Turniere {
const val ROOT = "$API_PREFIX/turniere"
fun bewerbe(turnierId: Long): String = "$ROOT/$turnierId/bewerbe"
}
object Bewerbe {
fun abteilungen(bewerbId: Long): String = "$API_PREFIX/bewerbe/$bewerbId/abteilungen"
}
}

View File

@ -0,0 +1 @@
// Removed: superseded by NetworkModule.kt

View File

@ -13,9 +13,7 @@ import org.koin.dsl.module
/**
* Schnittstelle zur Token-Bereitstellung entkoppelt core-network von core-auth.
*/
interface TokenProvider {
fun getAccessToken(): String?
}
interface TokenProvider { fun getAccessToken(): String? }
/**
* Koin-Modul mit zwei HttpClient-Instanzen:
@ -28,16 +26,15 @@ val networkModule = module {
single(named("baseHttpClient")) {
HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; isLenient = true; encodeDefaults = true })
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
println("[baseClient] $message")
json(
Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
}
level = LogLevel.INFO
)
}
install(Logging) { logger = Logger.SIMPLE; level = LogLevel.NONE }
}
}
@ -47,29 +44,29 @@ val networkModule = module {
HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; isLenient = true; encodeDefaults = true })
json(
Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
)
}
install(HttpTimeout) {
// Defaults laut Spezifikation (Prod)
connectTimeoutMillis = 5_000
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 15_000
socketTimeoutMillis = 30_000
}
install(HttpRequestRetry) {
maxRetries = 3
maxRetries = 2
retryIf { _, response -> response.status.value.let { it == 0 || it >= 500 } }
exponentialDelay()
}
defaultRequest {
url(NetworkConfig.baseUrl.trimEnd('/'))
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
println("[apiClient] $message")
}
}
level = LogLevel.INFO
}
install(Logging) { logger = Logger.SIMPLE; level = LogLevel.NONE }
}.also { client ->
// Bearer-Token pro Request dynamisch injizieren (lazy, damit kein Circular-Dependency)
client.plugin(HttpSend).intercept { request ->

View File

@ -0,0 +1,91 @@
package at.mocode.frontend.features.funktionaer.presentation
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 FunktionaerListItem(
val id: Long,
val name: String,
val rolle: String,
val lizenz: String?,
)
data class FunktionaerState(
val isLoading: Boolean = false,
val searchQuery: String = "",
val list: List<FunktionaerListItem> = emptyList(),
val filtered: List<FunktionaerListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
)
sealed interface FunktionaerIntent {
data object Load : FunktionaerIntent
data object Refresh : FunktionaerIntent
data class SearchChanged(val query: String) : FunktionaerIntent
data class Select(val id: Long?) : FunktionaerIntent
data object ClearError : FunktionaerIntent
}
interface FunktionaerRepository {
suspend fun list(): List<FunktionaerListItem>
}
class FunktionaerViewModel(
private val repo: FunktionaerRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(FunktionaerState(isLoading = true))
val state: StateFlow<FunktionaerState> = _state
init { send(FunktionaerIntent.Load) }
fun send(intent: FunktionaerIntent) {
when (intent) {
is FunktionaerIntent.Load, is FunktionaerIntent.Refresh -> load()
is FunktionaerIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is FunktionaerIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is FunktionaerIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val items = repo.list()
reduce { cur ->
val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered)
}
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
}
}
}
private fun filter() {
val cur = _state.value
val filtered = filterList(cur.list, cur.searchQuery)
reduce { it.copy(filtered = filtered) }
}
private fun filterList(list: List<FunktionaerListItem>, query: String): List<FunktionaerListItem> {
if (query.isBlank()) return list
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.rolle.contains(q, ignoreCase = true) ||
(it.lizenz?.contains(q, ignoreCase = true) ?: false)
}
}
private inline fun reduce(block: (FunktionaerState) -> FunktionaerState) {
_state.value = block(_state.value)
}
}

View File

@ -0,0 +1,99 @@
package at.mocode.frontend.features.pferde.presentation
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 PferdProfilState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val name: String = "",
val feiId: String = "",
val oepsNummer: String = "",
val geburtsjahr: String = "",
val farbe: String = "",
val rasse: String = "",
val validHints: List<String> = emptyList(),
val dirty: Boolean = false,
)
sealed interface PferdProfilIntent {
data class Load(val id: String) : PferdProfilIntent
data object Refresh : PferdProfilIntent
data class EditName(val v: String) : PferdProfilIntent
data class EditFeiId(val v: String) : PferdProfilIntent
data class EditOeps(val v: String) : PferdProfilIntent
data class EditGeburtsjahr(val v: String) : PferdProfilIntent
data class EditFarbe(val v: String) : PferdProfilIntent
data class EditRasse(val v: String) : PferdProfilIntent
data object Save : PferdProfilIntent
data object ClearError : PferdProfilIntent
}
interface PferdProfilRepository {
suspend fun load(id: String): PferdProfilState
suspend fun save(id: String, state: PferdProfilState)
}
class PferdProfilViewModel(
private val repo: PferdProfilRepository,
private var id: String,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(PferdProfilState(isLoading = true))
val state: StateFlow<PferdProfilState> = _state
init { send(PferdProfilIntent.Load(id)) }
fun send(intent: PferdProfilIntent) {
when (intent) {
is PferdProfilIntent.Load -> { id = intent.id; load() }
is PferdProfilIntent.Refresh -> load()
is PferdProfilIntent.EditName -> edit { it.copy(name = intent.v) }
is PferdProfilIntent.EditFeiId -> edit { it.copy(feiId = intent.v) }
is PferdProfilIntent.EditOeps -> edit { it.copy(oepsNummer = intent.v) }
is PferdProfilIntent.EditGeburtsjahr -> edit { it.copy(geburtsjahr = intent.v) }
is PferdProfilIntent.EditFarbe -> edit { it.copy(farbe = intent.v) }
is PferdProfilIntent.EditRasse -> edit { it.copy(rasse = intent.v) }
is PferdProfilIntent.Save -> save()
is PferdProfilIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val loaded = repo.load(id)
reduce { loaded.copy(isLoading = false, dirty = false) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
}
}
}
private fun save() {
val cur = _state.value
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
repo.save(id, cur)
reduce { it.copy(isLoading = false, dirty = false) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Speichern") }
}
}
}
private inline fun edit(block: (PferdProfilState) -> PferdProfilState) {
reduce { block(it).copy(dirty = true) }
}
private inline fun reduce(block: (PferdProfilState) -> PferdProfilState) {
_state.value = block(_state.value)
}
}

View File

@ -0,0 +1,99 @@
package at.mocode.frontend.features.reiter.presentation
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 ReiterProfilState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val vorname: String = "",
val nachname: String = "",
val oepsNummer: String = "",
val feiId: String = "",
val lizenzKlasse: String = "",
val verein: String = "",
val validHints: List<String> = emptyList(),
val dirty: Boolean = false,
)
sealed interface ReiterProfilIntent {
data class Load(val id: String) : ReiterProfilIntent
data object Refresh : ReiterProfilIntent
data class EditVorname(val v: String) : ReiterProfilIntent
data class EditNachname(val v: String) : ReiterProfilIntent
data class EditOeps(val v: String) : ReiterProfilIntent
data class EditFeiId(val v: String) : ReiterProfilIntent
data class EditLizenz(val v: String) : ReiterProfilIntent
data class EditVerein(val v: String) : ReiterProfilIntent
data object Save : ReiterProfilIntent
data object ClearError : ReiterProfilIntent
}
interface ReiterProfilRepository {
suspend fun load(id: String): ReiterProfilState
suspend fun save(id: String, state: ReiterProfilState)
}
class ReiterProfilViewModel(
private val repo: ReiterProfilRepository,
private var id: String,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(ReiterProfilState(isLoading = true))
val state: StateFlow<ReiterProfilState> = _state
init { send(ReiterProfilIntent.Load(id)) }
fun send(intent: ReiterProfilIntent) {
when (intent) {
is ReiterProfilIntent.Load -> { id = intent.id; load() }
is ReiterProfilIntent.Refresh -> load()
is ReiterProfilIntent.EditVorname -> edit { it.copy(vorname = intent.v) }
is ReiterProfilIntent.EditNachname -> edit { it.copy(nachname = intent.v) }
is ReiterProfilIntent.EditOeps -> edit { it.copy(oepsNummer = intent.v) }
is ReiterProfilIntent.EditFeiId -> edit { it.copy(feiId = intent.v) }
is ReiterProfilIntent.EditLizenz -> edit { it.copy(lizenzKlasse = intent.v) }
is ReiterProfilIntent.EditVerein -> edit { it.copy(verein = intent.v) }
is ReiterProfilIntent.Save -> save()
is ReiterProfilIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val loaded = repo.load(id)
reduce { loaded.copy(isLoading = false, dirty = false) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
}
}
}
private fun save() {
val cur = _state.value
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
repo.save(id, cur)
reduce { it.copy(isLoading = false, dirty = false) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Speichern") }
}
}
}
private inline fun edit(block: (ReiterProfilState) -> ReiterProfilState) {
reduce { block(it).copy(dirty = true) }
}
private inline fun reduce(block: (ReiterProfilState) -> ReiterProfilState) {
_state.value = block(_state.value)
}
}

View File

@ -0,0 +1,15 @@
package at.mocode.turnier.feature.domain
data class Abteilung(
val id: Long,
val bewerbId: Long,
val name: String,
)
interface AbteilungRepository {
suspend fun list(bewerbId: Long): Result<List<Abteilung>>
suspend fun getById(id: Long): Result<Abteilung>
suspend fun create(model: Abteilung): Result<Abteilung>
suspend fun update(id: Long, model: Abteilung): Result<Abteilung>
suspend fun delete(id: Long): Result<Unit>
}

View File

@ -0,0 +1,15 @@
package at.mocode.turnier.feature.domain
data class Bewerb(
val id: Long,
val turnierId: Long,
val name: String,
)
interface BewerbRepository {
suspend fun list(turnierId: Long): Result<List<Bewerb>>
suspend fun getById(id: Long): Result<Bewerb>
suspend fun create(model: Bewerb): Result<Bewerb>
suspend fun update(id: Long, model: Bewerb): Result<Bewerb>
suspend fun delete(id: Long): Result<Unit>
}

View File

@ -0,0 +1,14 @@
package at.mocode.turnier.feature.domain
data class Turnier(
val id: Long,
val name: String,
)
interface TurnierRepository {
suspend fun list(): Result<List<Turnier>>
suspend fun getById(id: Long): Result<Turnier>
suspend fun create(model: Turnier): Result<Turnier>
suspend fun update(id: Long, model: Turnier): Result<Turnier>
suspend fun delete(id: Long): Result<Unit>
}

View File

@ -0,0 +1,132 @@
package at.mocode.turnier.feature.presentation
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 StartlistenEintrag(
val startNr: Int,
val reiterName: String,
val pferdeName: String,
)
data class ErgebnisEintrag(
val startNr: Int,
val punkte: Double?,
val rang: Int?,
)
data class AbteilungState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val startliste: List<StartlistenEintrag> = emptyList(),
val ergebnisse: List<ErgebnisEintrag> = emptyList(),
)
sealed interface AbteilungIntent {
data class LoadByBewerb(val bewerbId: Long, val abteilungsNr: Int) : AbteilungIntent
data object Refresh : AbteilungIntent
data class UpdateErgebnis(val startNr: Int, val punkte: Double?) : AbteilungIntent
data class ReorderStartliste(val fromIndex: Int, val toIndex: Int) : AbteilungIntent
data object Publish : AbteilungIntent
data object ClearError : AbteilungIntent
}
interface AbteilungRepository {
suspend fun loadStartliste(bewerbId: Long, abteilungsNr: Int): List<StartlistenEintrag>
suspend fun loadErgebnisse(bewerbId: Long, abteilungsNr: Int): List<ErgebnisEintrag>
suspend fun saveErgebnis(bewerbId: Long, abteilungsNr: Int, startNr: Int, punkte: Double?)
suspend fun saveStartlistenOrder(bewerbId: Long, abteilungsNr: Int, orderedStartNr: List<Int>)
suspend fun publish(bewerbId: Long, abteilungsNr: Int)
}
class AbteilungViewModel(
private val repo: AbteilungRepository,
private var bewerbId: Long,
private var abteilungsNr: Int,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(AbteilungState(isLoading = true))
val state: StateFlow<AbteilungState> = _state
init {
send(AbteilungIntent.LoadByBewerb(bewerbId, abteilungsNr))
}
fun send(intent: AbteilungIntent) {
when (intent) {
is AbteilungIntent.LoadByBewerb -> {
bewerbId = intent.bewerbId
abteilungsNr = intent.abteilungsNr
load()
}
is AbteilungIntent.Refresh -> load()
is AbteilungIntent.UpdateErgebnis -> updateErgebnis(intent.startNr, intent.punkte)
is AbteilungIntent.ReorderStartliste -> reorder(intent.fromIndex, intent.toIndex)
is AbteilungIntent.Publish -> publish()
is AbteilungIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val start = repo.loadStartliste(bewerbId, abteilungsNr)
val erg = repo.loadErgebnisse(bewerbId, abteilungsNr)
reduce { it.copy(isLoading = false, startliste = start, ergebnisse = erg) }
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
}
}
}
private fun updateErgebnis(startNr: Int, punkte: Double?) {
scope.launch {
try {
repo.saveErgebnis(bewerbId, abteilungsNr, startNr, punkte)
// Lokale Spiegelung
val newErg = state.value.ergebnisse.toMutableList()
val idx = newErg.indexOfFirst { it.startNr == startNr }
if (idx >= 0) newErg[idx] = newErg[idx].copy(punkte = punkte) else newErg += ErgebnisEintrag(startNr, punkte, null)
reduce { it.copy(ergebnisse = newErg) }
} catch (t: Throwable) {
reduce { it.copy(errorMessage = t.message ?: "Fehler beim Speichern") }
}
}
}
private fun reorder(fromIndex: Int, toIndex: Int) {
val list = state.value.startliste.toMutableList()
if (fromIndex in list.indices && toIndex in list.indices) {
val item = list.removeAt(fromIndex)
list.add(toIndex, item)
reduce { it.copy(startliste = list) }
scope.launch {
try {
repo.saveStartlistenOrder(bewerbId, abteilungsNr, list.map { it.startNr })
} catch (t: Throwable) {
reduce { it.copy(errorMessage = t.message ?: "Fehler beim Speichern der Reihenfolge") }
}
}
}
}
private fun publish() {
scope.launch {
try {
repo.publish(bewerbId, abteilungsNr)
} catch (t: Throwable) {
reduce { it.copy(errorMessage = t.message ?: "Fehler beim Veröffentlichen") }
}
}
}
private inline fun reduce(block: (AbteilungState) -> AbteilungState) {
_state.value = block(_state.value)
}
}

View File

@ -0,0 +1,130 @@
package at.mocode.turnier.feature.presentation
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 BewerbListItem(
val id: Long,
val tag: String,
val platz: Int,
val name: String,
val sparte: String,
val klasse: String,
val nennungen: Int,
)
data class BewerbState(
val isLoading: Boolean = false,
val searchQuery: String = "",
val list: List<BewerbListItem> = emptyList(),
val filtered: List<BewerbListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
)
sealed interface BewerbIntent {
data object Load : BewerbIntent
data object Refresh : BewerbIntent
data class SearchChanged(val query: String) : BewerbIntent
data class Select(val id: Long?) : BewerbIntent
data object ClearError : BewerbIntent
// Delegation an Dialog-VM
data object OpenDialog : BewerbIntent
data object CloseDialog : BewerbIntent
data class SetBewerbsTyp(val typ: String) : BewerbIntent
data class SetAbteilungsTyp(val typ: AbteilungsTyp) : BewerbIntent
}
interface BewerbRepository {
suspend fun listByTurnier(turnierId: Long): List<BewerbListItem>
}
class BewerbViewModel(
private val repo: BewerbRepository,
private val turnierId: Long,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(BewerbState(isLoading = true))
val state: StateFlow<BewerbState> = _state
// Interne Instanz des DialogVM (teilen State via Kopie)
private val dialogVm = BewerbAnlegenViewModel()
init {
send(BewerbIntent.Load)
}
fun send(intent: BewerbIntent) {
when (intent) {
is BewerbIntent.Load, is BewerbIntent.Refresh -> load()
is BewerbIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is BewerbIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is BewerbIntent.ClearError -> reduce { it.copy(errorMessage = null) }
is BewerbIntent.OpenDialog -> {
dialogVm.send(BewerbAnlegenIntent.Open)
syncDialogState()
}
is BewerbIntent.CloseDialog -> {
dialogVm.send(BewerbAnlegenIntent.Close)
syncDialogState()
}
is BewerbIntent.SetBewerbsTyp -> {
dialogVm.send(BewerbAnlegenIntent.SetBewerbsTyp(intent.typ))
syncDialogState()
}
is BewerbIntent.SetAbteilungsTyp -> {
dialogVm.send(BewerbAnlegenIntent.SetAbteilungsTyp(intent.typ))
syncDialogState()
}
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val items = repo.listByTurnier(turnierId)
reduce { cur ->
val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered)
}
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Unbekannter Fehler beim Laden") }
}
}
}
private fun filter() {
val cur = _state.value
val filtered = filterList(cur.list, cur.searchQuery)
reduce { it.copy(filtered = filtered) }
}
private fun filterList(list: List<BewerbListItem>, query: String): List<BewerbListItem> {
if (query.isBlank()) return list
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.sparte.contains(q, ignoreCase = true) ||
it.klasse.contains(q, ignoreCase = true) ||
it.tag.contains(q, ignoreCase = true)
}
}
private fun syncDialogState() {
_state.value = _state.value.copy(dialogState = dialogVm.state.value)
}
private inline fun reduce(block: (BewerbState) -> BewerbState) {
_state.value = block(_state.value)
}
}

View File

@ -0,0 +1,98 @@
package at.mocode.turnier.feature.presentation
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
// UI-optimiertes List Item für Turniere (unabhängig vom Domänenmodell)
data class TurnierListItem(
val id: Long,
val name: String,
val ort: String,
val startDatum: String,
val endDatum: String,
val status: String,
)
data class TurnierState(
val isLoading: Boolean = false,
val searchQuery: String = "",
val list: List<TurnierListItem> = emptyList(),
val filtered: List<TurnierListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
)
sealed interface TurnierIntent {
data object Load : TurnierIntent
data object Refresh : TurnierIntent
data class SearchChanged(val query: String) : TurnierIntent
data class Select(val id: Long?) : TurnierIntent
data object ClearError : TurnierIntent
}
interface TurnierRepository {
suspend fun list(): List<TurnierListItem>
// Platzhalter für B-2: suspend fun get(id: Long): TurnierDetail
}
class TurnierViewModel(
private val repo: TurnierRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(TurnierState(isLoading = true))
val state: StateFlow<TurnierState> = _state
init {
send(TurnierIntent.Load)
}
fun send(intent: TurnierIntent) {
when (intent) {
is TurnierIntent.Load -> load()
is TurnierIntent.Refresh -> load()
is TurnierIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is TurnierIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is TurnierIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val items = repo.list()
reduce { cur ->
val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered)
}
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Unbekannter Fehler beim Laden") }
}
}
}
private fun filter() {
val cur = _state.value
val filtered = filterList(cur.list, cur.searchQuery)
reduce { it.copy(filtered = filtered) }
}
private fun filterList(list: List<TurnierListItem>, query: String): List<TurnierListItem> {
if (query.isBlank()) return list
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.ort.contains(q, ignoreCase = true) ||
it.status.contains(q, ignoreCase = true)
}
}
private inline fun reduce(block: (TurnierState) -> TurnierState) {
_state.value = block(_state.value)
}
}

View File

@ -15,6 +15,7 @@ kotlin {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.navigation)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
@ -26,6 +27,8 @@ kotlin {
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// Ktor client for repository implementation
implementation(libs.ktor.client.core)
}
}
}

View File

@ -0,0 +1,20 @@
package at.mocode.frontend.features.veranstalter.data.mapper
import at.mocode.frontend.features.veranstalter.data.remote.dto.VeranstalterDto
import at.mocode.frontend.features.veranstalter.domain.Veranstalter
fun VeranstalterDto.toDomain(): Veranstalter = Veranstalter(
id = id,
name = name,
oepsNummer = oepsNummer,
ort = ort,
loginStatus = loginStatus,
)
fun Veranstalter.toDto(): VeranstalterDto = VeranstalterDto(
id = id,
name = name,
oepsNummer = oepsNummer,
ort = ort,
loginStatus = loginStatus,
)

View File

@ -0,0 +1,12 @@
package at.mocode.frontend.features.veranstalter.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class VeranstalterDto(
val id: Long,
val name: String,
val oepsNummer: String,
val ort: String,
val loginStatus: String,
)

View File

@ -0,0 +1,23 @@
package at.mocode.frontend.features.veranstalter.domain
/**
* Domänenmodell für Veranstalter (V3-Minimum für Listenansicht).
*/
data class Veranstalter(
val id: Long,
val name: String,
val oepsNummer: String,
val ort: String,
val loginStatus: String,
)
/**
* Repository-Vertrag (commonMain) austauschbar zwischen Mock und Real.
*/
interface VeranstalterRepository {
suspend fun list(): Result<List<Veranstalter>>
suspend fun getById(id: Long): Result<Veranstalter>
suspend fun create(model: Veranstalter): Result<Veranstalter>
suspend fun update(id: Long, model: Veranstalter): Result<Veranstalter>
suspend fun delete(id: Long): Result<Unit>
}

View File

@ -6,6 +6,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter
// UDF: State beschreibt die gesamte UI in einem Snapshot
data class VeranstalterState(
@ -34,11 +36,6 @@ data class VeranstalterListItem(
val loginStatus: String,
)
// Repository-Vertrag (später gegen echte Backend-Repositories austauschbar)
interface VeranstalterRepository {
suspend fun list(): List<VeranstalterListItem>
}
class VeranstalterViewModel(
private val repo: VeranstalterRepository,
) {
@ -64,14 +61,14 @@ class VeranstalterViewModel(
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val items = repo.list()
// Nach dem Laden auch initial filtern
val result = repo.list()
result.onSuccess { domainList ->
val items = domainList.map { it.toListItem() }
reduce { cur ->
val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered)
}
} catch (t: Throwable) {
}.onFailure { t ->
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Unbekannter Fehler beim Laden") }
}
}
@ -97,3 +94,11 @@ class VeranstalterViewModel(
_state.value = block(_state.value)
}
}
private fun DomainVeranstalter.toListItem() = VeranstalterListItem(
id = id,
name = name,
oepsNummer = oepsNummer,
ort = ort,
loginStatus = loginStatus,
)

View File

@ -0,0 +1,79 @@
package at.mocode.frontend.features.veranstalter.data.remote
import at.mocode.frontend.core.network.ApiRoutes
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)
}
}
}
// Fehler-Typen (vereinfachtes DomainError-Äquivalent)
class AuthExpired : RuntimeException("AUTH_EXPIRED")
class AuthForbidden : RuntimeException("AUTH_FORBIDDEN")
class NotFound : RuntimeException("NOT_FOUND")
class Conflict : RuntimeException("CONFLICT")
class ServerError : RuntimeException("SERVER_ERROR")
class HttpError(val code: Int) : RuntimeException("HTTP_$code")

View File

@ -0,0 +1,10 @@
package at.mocode.frontend.features.veranstalter.di
import at.mocode.frontend.features.veranstalter.data.remote.DefaultVeranstalterRepository
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import org.koin.core.qualifier.named
import org.koin.dsl.module
val veranstalterModule = module {
single<VeranstalterRepository> { DefaultVeranstalterRepository(get(named("apiClient"))) }
}

View File

@ -1,23 +1 @@
package at.mocode.veranstalter.feature.presentation
import at.mocode.frontend.core.designsystem.models.LoginStatus
class DefaultVeranstalterRepository : VeranstalterRepository {
override suspend fun list(): List<VeranstalterListItem> {
// Aus Fake-Store lesen (Prototyp)
return FakeVeranstalterStore.all().map { it.toListItem() }
}
}
private fun LoginStatus.asLabel(): String = when (this) {
LoginStatus.AKTIV -> "AKTIV"
LoginStatus.AUSSTEHEND -> "AUSSTEHEND"
}
private fun VeranstalterUiModel.toListItem() = VeranstalterListItem(
id = id,
name = name,
oepsNummer = oepsNummer,
ort = ort,
loginStatus = loginStatus.asLabel(),
)
// Deprecated fake repository removed in favor of real Ktor-backed implementation.

View File

@ -44,7 +44,8 @@ fun VeranstalterAuswahlScreen(
onNeuerVeranstalter: () -> Unit = {},
) {
// MVVM + UDF: ViewModel hält gesamten Zustand, Composable rendert nur State und sendet Intents
val viewModel = remember { VeranstalterViewModel(DefaultVeranstalterRepository()) }
val repo: at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository = org.koin.compose.koinInject()
val viewModel = remember { VeranstalterViewModel(repo) }
val state by viewModel.state.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {

View File

@ -7,8 +7,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
@ -175,7 +175,7 @@ fun VeranstalterAuswahlV2(
) {
Text("Weiter zur Turnier-Konfiguration")
Spacer(Modifier.width(8.dp))
Icon(Icons.Default.ArrowForward, null, modifier = Modifier.size(16.dp))
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(16.dp))
}
}
}

View File

@ -0,0 +1,91 @@
package at.mocode.frontend.features.verein.presentation
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 VereinsListItem(
val id: Long,
val name: String,
val ort: String,
val oepsNummer: String,
)
data class VereinsState(
val isLoading: Boolean = false,
val searchQuery: String = "",
val list: List<VereinsListItem> = emptyList(),
val filtered: List<VereinsListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
)
sealed interface VereinsIntent {
data object Load : VereinsIntent
data object Refresh : VereinsIntent
data class SearchChanged(val query: String) : VereinsIntent
data class Select(val id: Long?) : VereinsIntent
data object ClearError : VereinsIntent
}
interface VereinsRepository {
suspend fun list(): List<VereinsListItem>
}
class VereinsViewModel(
private val repo: VereinsRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(VereinsState(isLoading = true))
val state: StateFlow<VereinsState> = _state
init { send(VereinsIntent.Load) }
fun send(intent: VereinsIntent) {
when (intent) {
is VereinsIntent.Load, is VereinsIntent.Refresh -> load()
is VereinsIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is VereinsIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is VereinsIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
private fun load() {
reduce { it.copy(isLoading = true, errorMessage = null) }
scope.launch {
try {
val items = repo.list()
reduce { cur ->
val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered)
}
} catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
}
}
}
private fun filter() {
val cur = _state.value
val filtered = filterList(cur.list, cur.searchQuery)
reduce { it.copy(filtered = filtered) }
}
private fun filterList(list: List<VereinsListItem>, query: String): List<VereinsListItem> {
if (query.isBlank()) return list
val q = query.trim()
return list.filter {
it.name.contains(q, ignoreCase = true) ||
it.ort.contains(q, ignoreCase = true) ||
it.oepsNummer.contains(q, ignoreCase = true)
}
}
private inline fun reduce(block: (VereinsState) -> VereinsState) {
_state.value = block(_state.value)
}
}