Compare commits
7 Commits
85282ea7b4
...
2c8d16b27f
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c8d16b27f | |||
| f82dbd64a5 | |||
| a5c1fb5bae | |||
| 62c0d9d75c | |||
| 2b3e2d8c1b | |||
| 48ffadaaa2 | |||
| c483f4925d |
63
.gitea/workflows/desktop-tests.yml
Normal file
63
.gitea/workflows/desktop-tests.yml
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
package at.mocode.entries.service.errors
|
||||
|
||||
class ValidationException(message: String) : RuntimeException(message)
|
||||
class LockedException(message: String) : RuntimeException(message)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (Tab‑Root)“
|
||||
- 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)“
|
||||
- Status‑Anzeige/Listenkarte — Beleg: `docs/06_Frontend/Screenshots/Veranstaltungen-Status-Anzeige_entwurf-01.png`
|
||||
- Tab‑Root 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: 8‑pt 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. Screen‑Title vs. Section‑Title).
|
||||
- Fehlende definierte Caption/Overline‑Stile für Badges/Status.
|
||||
- Farben
|
||||
- Primärfarbe variiert zwischen Blau‑Tönen; Sekundär/Info vs. Accent unscharf. MaterialTheme Tokens konsolidieren (V3 C‑Palette).
|
||||
- Statusfarben (OK/Warn/Error) ohne konsistente Kontraststufen und ohne Dark‑Mode‑Fallbacks.
|
||||
- Komponenten
|
||||
- Heterogene Kartenrahmen (Elevation vs. Border). Einheitliche „MeldestelleCard“ definieren.
|
||||
- Uneinheitliche „SectionHeader“ (Icon/Spacing/Divider). Standard‑Composable nötig.
|
||||
|
||||
3) Fehlende/ungenügende Empty States (Beispiele, nicht abschließend)
|
||||
- Veranstaltungen (keine Veranstaltungen) — Call‑to‑Action „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 Logout‑Aktion im MVP).
|
||||
- Kassa‑Screens als Push über Detail beibehalten (Back kehrt korrekt zurück; kein eigener Tab).
|
||||
- Dialoge/Sheets nicht im Back‑Stack verewigen; Schließen führt zum Ursprungs‑Screen.
|
||||
|
||||
5) Abgeleitete Issue‑Liste für Sprint B (konkret, klein schneidbar)
|
||||
- B-0: Design‑Tokens 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: 8‑pt Grid als Standard definieren (Außen 16, Innen 8/12).
|
||||
- B-1a: „MeldestelleCard“ Standard‑Composable erstellen (Padding, Border/Elevation, Title‑Slot, Actions‑Slot).
|
||||
- B-1b: „SectionHeader“ Standard‑Composable (Icon optional, Title, Subline, Divider‑Option, Standard‑Spacings).
|
||||
- B-4a: Empty‑State Vorlage erstellen (Icon/Illustration + Title + Body + Primary‑CTA + Secondary‑Link) und für 5 Listen anwenden.
|
||||
- B-2a: Wireframe „Bewerb anlegen“ inkl. Abteilungs‑Vorschlag (CSN‑C‑NEU Pflicht‑Teilung klar visualisieren).
|
||||
- B-3a: Wireframe „Kassa Turnier/Veranstaltung“ inkl. Zahlungsaufteilung und Rechnungsvorschau (Tabs oder Side‑by‑Side) verfeinern.
|
||||
- B-1c: Entscheidungsrichtlinie Dialog vs. Fullscreen‑Edit als kurze Guideline in `docs/06_Frontend/` publizieren.
|
||||
|
||||
Hinweise zur Ablage
|
||||
- Wireframes und UI‑Guidelines bitte unter `docs/06_Frontend/` versionieren; Screenshots ergänzen unter `docs/06_Frontend/Screenshots/`.
|
||||
- V2‑Verweise 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
|
||||
|
|
|
|||
|
|
@ -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, High‑Density Desktop UX, Offline‑First.
|
||||
|
||||
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/Async‑Laden
|
||||
- Sofortige Aktion, geringe Tragweite, seltene Nutzung
|
||||
- Dauer des Vorgangs < 20 s
|
||||
- Back‑Stack: kein Eintrag (Schließen kehrt zum Ursprungs‑Screen)
|
||||
|
||||
- Sliding Panel (Side/Bottom Sheet)
|
||||
- Felder: 3–8, mittlere Komplexität, evtl. Sektionen
|
||||
- Kontext muss sichtbar bleiben (Liste/Detail im Hintergrund relevant)
|
||||
- Leichte Abhängigkeiten erlaubt (z. B. abhängige Selects), klare Inline‑Validierung
|
||||
- Dauer 20–60 s
|
||||
- Back‑Stack: 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 Tastatur‑Fokusstrecke, Hotkeys, Review/Preview
|
||||
- Dauer > 60 s
|
||||
- Back‑Stack: eigener Eintrag; Zurück führt sicher zurück, Unsaved‑Changes‑Guard aktiv
|
||||
|
||||
Hinweis: Auf Desktop bevorzugen wir Side Sheet (rechts) als „Kontext‑Erhalt bei mittlerer Komplexität“.
|
||||
|
||||
---
|
||||
|
||||
## Do/Don’t (Auszug)
|
||||
|
||||
- Don’t: Mehr als 3 Eingabefelder in AlertDialogs stapeln → führt zu Scrollen/Übersehen.
|
||||
- Do: Kurze Bestätigungen/Umbenennen/Flag toggeln als Dialog lösen.
|
||||
- Don’t: Async‑Suchfelder im Dialog (z. B. Reiter‑Lookup) → Side Sheet oder Fullscreen.
|
||||
- Do: Unsaved‑Changes vor Navigation abfangen (Confirm‑Dialog, kein Stack‑Eintrag für Modal/Sheet; V3‑Regel).
|
||||
|
||||
---
|
||||
|
||||
## Beispiel A — Reiter bearbeiten (Side Sheet empfohlen)
|
||||
|
||||
Kontext: Edit von Stammdaten, typ. 4–7 Felder (Name, Lizenzklasse, Verein, Notizen, aktiv). Liste/Detail im Blick behalten (Suche, Auswahl vergleichen).
|
||||
|
||||
ASCII‑Wireframe (Desktop, Right Side Sheet ~420–520 px):
|
||||
|
||||
```
|
||||
┌──────────────────────────── AppContent (z. B. Reiter‑Liste) ────────────────────────────┐┌───────────── 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; Verein‑Lookup als Such‑DropDown (debounced, Offline‑Cache).
|
||||
- Schließen: ESC/✕/Abbrechen; bei Änderungen Confirm‑Dialog (Unsaved‑Changes‑Guard).
|
||||
|
||||
---
|
||||
|
||||
## Beispiel B — Pferd bearbeiten (Fullscreen empfohlen)
|
||||
|
||||
Kontext: Mehr Felder und Abhängigkeiten (Name, Jahrgang, Geschlecht, Rasse, Besitzer/Verein, Registrier‑Nrn., Notizen). Teilweise Lookups, ggf. Bild.
|
||||
|
||||
ASCII‑Wireframe (Dedicated Screen):
|
||||
|
||||
```
|
||||
TopBar: ← Pferd bearbeiten [Abbrechen] [Speichern]
|
||||
Breadcrumb: Reiter & Pferde > Pferd.Detail > Edit
|
||||
|
||||
┌─ MeldestelleCard: Stammdaten ───────────────────────────────────────────────────────┐
|
||||
│ Name [ ....................... ] Jahrgang [ 20__ ] Geschlecht [v] │
|
||||
│ Rasse [ ....................... ] Farbe [ ..... ] │
|
||||
│ Registrier‑Nr. [ ....................... ] Chip‑Nr. [ ..... ] │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ MeldestelleCard: Zuordnung ────────────────────────────────────────────────────────┐
|
||||
│ Besitzer [ Suche Person/Verein 🔎 ] Verein [ Suche Verein 🔎 ] │
|
||||
│ Lizenzzuordnung [ None v ] (Info‑Badge zu Regeln) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ Notizen ────────────────────────────────────────────────────────────────────────────┐
|
||||
│ [ Mehrzeiliges Textfeld ………………………………………… ] │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Interaktion:
|
||||
- Eigener Screen mit vollem Tastatur‑Support, Fokus‑Reihenfolge, Hotkeys (Ctrl+S speichert).
|
||||
- Unsaved‑Changes‑Guard 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: Per‑Field Spinner bei Lookups; Gesamtscreen nie blockieren.
|
||||
- Accessibility: Tab‑Order, sichtbare Fokus‑Ringe, Labels neben/über Feldern (Dichte abhängig).
|
||||
|
||||
---
|
||||
|
||||
## Navigation & Back‑Stack (V3‑Konformität)
|
||||
|
||||
- Dialog/Sheet: Kein eigener Stack‑Eintrag; Schließen führt zum Ursprungs‑Screen zurück.
|
||||
- Fullscreen: Eigener Stack‑Eintrag. Deep‑Link baut synthetische Eltern auf (siehe V3).
|
||||
- Vor Navigation prüfen: Unsaved‑Changes → Confirm‑Dialog.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
|
@ -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 Abteilungs‑Logik)
|
||||
|
||||
Ziel: Klarer 3‑Step‑Flow für das Anlegen eines Bewerbs inkl. automatischer Abteilungs‑Vorschläge gemäß CSN‑C‑NEU Pflicht‑Teilung. Desktop‑optimiert (Compose), Offline‑Friendly.
|
||||
|
||||
Flow: Grunddaten → Abteilungs‑Vorschlag → Bestätigung.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Grunddaten
|
||||
|
||||
ASCII‑Wireframe (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 — Abteilungs‑Vorschlag (CSN‑C‑NEU)
|
||||
|
||||
Visualisierung der automatischen Vorschläge, klare Unterscheidung der Abteilungs‑Typen:
|
||||
|
||||
```
|
||||
┌──── Bewerb anlegen — Abteilungs‑Vorschlag ─────┐
|
||||
│ Vorschläge (automatisch): │
|
||||
│ ▣ SEPARATE_SIEGEREHRUNG (Pflicht‑Teilung) │
|
||||
│ • 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 Pflicht‑Teilung ] │
|
||||
│ │
|
||||
│ ← Zurück Weiter → │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
UI‑Muster:
|
||||
- Toggle zwischen `SEPARATE_SIEGEREHRUNG` und `ORGANISATORISCH` als RadioGroup mit erklärendem Subtext.
|
||||
- „Zusammenlegen“ Checkbox erlaubt in Grenzfällen organisatorisches Zusammenführen; deaktiviert bei harter Pflicht‑Teilung.
|
||||
- Badge/Hint „CSN‑C‑NEU: Pflicht‑Teilung 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“.
|
||||
- Backend‑Konflikte (Dubletten) als Inline‑Fehler mit CTA „Zum bestehenden Bewerb“.
|
||||
|
||||
---
|
||||
|
||||
## Komponenten & Implementierungshinweise
|
||||
|
||||
- Reusable: `FormRow`, `RadioCard` (Typ‑Auswahl mit Subtext), `DivisionPreviewChips` (Abteilungs‑Chips), `InfoBadge`.
|
||||
- Accessibility: Fokus‑Reihenfolge, ARIA‑Rollen analog (Compose Semantics).
|
||||
- Offline: Vorschläge aus lokalem Regelwerk + Cache berechnen; Sync klärt spätere Abweichungen.
|
||||
|
||||
---
|
||||
|
||||
## Edge‑Cases
|
||||
|
||||
- Pflicht‑Teilung 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?“.
|
||||
111
docs/06_Frontend/Wireframes/Kassa_Veranstaltung_v1.md
Normal file
111
docs/06_Frontend/Wireframes/Kassa_Veranstaltung_v1.md
Normal 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 — Veranstaltungs‑Kassa
|
||||
|
||||
Ziel: Übersicht über offene Beträge der Teilnehmer über mehrere Turniere einer Veranstaltung, klarer Zahlungsflow mit Aufteilung und Rechnungsvorschau. V3‑konforme Navigation (Push über Detail; Back geht zurück).
|
||||
|
||||
---
|
||||
|
||||
## A. Gesamt‑Saldo‑Ansicht (Event‑Ebene)
|
||||
|
||||
```
|
||||
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 Side‑Panel‑Auflistung je Turnier.
|
||||
```
|
||||
|
||||
Side Panel „Teilnehmer‑Detail“ (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. Zahlvorgang‑Dialog (Aufteilung über Turniere)
|
||||
|
||||
```
|
||||
┌──────── Zahlung erfassen — Anna Mayer ────────┐
|
||||
│ Eingabe: │
|
||||
│ Betrag erhalten [ 75,00 ] Währung [€] │
|
||||
│ Zahlungsart [ Bar v ] │
|
||||
│ Beleg‑Nr. [ ....... ] (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 Top‑Down nach offenem Betrag; editierbar.
|
||||
- Restbetrag darf nicht negativ sein; Validierungs‑Hinweis inline.
|
||||
- Speicherung erzeugt Buchungen je Turnier (Transaktion), Offline‑fähig mit Pending‑Status.
|
||||
|
||||
---
|
||||
|
||||
## C. Rechnungsvorschau (Tabs oder Side‑by‑Side)
|
||||
|
||||
Variante 1 — Tabs (einfacher, platzsparend):
|
||||
|
||||
```
|
||||
┌──────── Rechnungsvorschau — Anna Mayer ────────┐
|
||||
│ Tabs: [ Springen SA ] [ Dressur SO ] │
|
||||
│ │
|
||||
│ (PDF‑Preview/Komposition) │
|
||||
│ │
|
||||
│ [← Zurück] [Buchen & Drucken] │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Variante 2 — Side‑by‑Side (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; Side‑by‑Side 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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
// Removed: superseded by NetworkModule.kt
|
||||
|
|
@ -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 })
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
install(Logging) {
|
||||
logger = object : Logger {
|
||||
override fun log(message: String) {
|
||||
println("[baseClient] $message")
|
||||
}
|
||||
}
|
||||
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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 Dialog‑VM (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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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"))) }
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user