chore: implement Turnier domain logic, add repository interfaces and default implementations, and disable WASM builds
This commit is contained in:
+52
@@ -0,0 +1,52 @@
|
||||
package at.mocode.frontend.features.turnier.data.mapper
|
||||
|
||||
import at.mocode.frontend.features.turnier.data.remote.dto.AbteilungDto
|
||||
import at.mocode.frontend.features.turnier.data.remote.dto.AbteilungsWarnungDto
|
||||
import at.mocode.frontend.features.turnier.data.remote.dto.BewerbDto
|
||||
import at.mocode.frontend.features.turnier.data.remote.dto.TurnierDto
|
||||
import at.mocode.frontend.features.turnier.domain.Abteilung
|
||||
import at.mocode.frontend.features.turnier.domain.AbteilungsWarnung
|
||||
import at.mocode.frontend.features.turnier.domain.Bewerb
|
||||
import at.mocode.frontend.features.turnier.domain.Turnier
|
||||
|
||||
fun TurnierDto.toDomain(): Turnier = Turnier(id = id, name = name)
|
||||
fun Turnier.toDto(): TurnierDto = TurnierDto(id = id, name = name)
|
||||
|
||||
fun BewerbDto.toDomain(): Bewerb = Bewerb(
|
||||
id = id,
|
||||
turnierId = turnierId,
|
||||
tag = tag,
|
||||
platz = platz,
|
||||
name = name,
|
||||
sparte = sparte,
|
||||
klasse = klasse,
|
||||
nennungen = nennungen,
|
||||
geplantesDatum = geplantesDatum,
|
||||
beginnZeit = beginnZeit,
|
||||
reitdauerMinuten = reitdauerMinuten,
|
||||
umbauMinuten = umbauMinuten,
|
||||
besichtigungMinuten = besichtigungMinuten,
|
||||
austragungsplatzId = austragungsplatzId,
|
||||
warnungen = warnungen.map { AbteilungsWarnung(it.code, it.nachricht, it.oetoParagraph) }
|
||||
)
|
||||
|
||||
fun Bewerb.toDto(): BewerbDto = BewerbDto(
|
||||
id = id,
|
||||
turnierId = turnierId,
|
||||
tag = tag,
|
||||
platz = platz,
|
||||
name = name,
|
||||
sparte = sparte,
|
||||
klasse = klasse,
|
||||
nennungen = nennungen,
|
||||
geplantesDatum = geplantesDatum,
|
||||
beginnZeit = beginnZeit,
|
||||
reitdauerMinuten = reitdauerMinuten,
|
||||
umbauMinuten = umbauMinuten,
|
||||
besichtigungMinuten = besichtigungMinuten,
|
||||
austragungsplatzId = austragungsplatzId,
|
||||
warnungen = warnungen.map { AbteilungsWarnungDto(it.code, it.nachricht, it.oetoParagraph) }
|
||||
)
|
||||
|
||||
fun AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name)
|
||||
fun Abteilung.toDto(): AbteilungDto = AbteilungDto(id = id, bewerbId = bewerbId, name = name)
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RichterEinsatzDto(
|
||||
val funktionaerId: String,
|
||||
val position: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateBewerbPayload(
|
||||
// Basis
|
||||
val klasse: String,
|
||||
val hoeheCm: Int? = null,
|
||||
val bezeichnung: String,
|
||||
|
||||
// Text & Details
|
||||
val beschreibung: String? = null,
|
||||
val aufgabe: String? = null,
|
||||
val aufgabenNummer: String? = null,
|
||||
val paraGrade: String? = null,
|
||||
|
||||
// Ort & Funktionäre
|
||||
val austragungsplatzId: String? = null,
|
||||
val richterEinsaetze: List<RichterEinsatzDto> = emptyList(),
|
||||
|
||||
// Zeitplan
|
||||
val geplantesDatum: LocalDate? = null,
|
||||
@SerialName("beginnZeitTyp") val beginnZeitTyp: String? = null, // enum name
|
||||
val beginnZeit: LocalTime? = null,
|
||||
val reitdauerMinuten: Int? = null,
|
||||
val umbauMinuten: Int? = null,
|
||||
val besichtigungMinuten: Int? = null,
|
||||
val stechenGeplant: Boolean = false,
|
||||
|
||||
// Finanzen
|
||||
val startgeldCent: Long? = null,
|
||||
val geldpreisAusbezahlt: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BewerbResponse(
|
||||
val id: String,
|
||||
val turnierId: String,
|
||||
val klasse: String,
|
||||
val hoeheCm: Int? = null,
|
||||
val bezeichnung: String,
|
||||
|
||||
// Text & Details
|
||||
val beschreibung: String? = null,
|
||||
val aufgabe: String? = null,
|
||||
val aufgabenNummer: String? = null,
|
||||
val paraGrade: String? = null,
|
||||
|
||||
// Ort & Funktionäre
|
||||
val austragungsplatzId: String? = null,
|
||||
val richterEinsaetze: List<RichterEinsatzDto> = emptyList(),
|
||||
|
||||
// Zeitplan
|
||||
val geplantesDatum: LocalDate? = null,
|
||||
@SerialName("beginnZeitTyp") val beginnZeitTyp: String? = null,
|
||||
val beginnZeit: LocalTime? = null,
|
||||
val reitdauerMinuten: Int? = null,
|
||||
val umbauMinuten: Int? = null,
|
||||
val besichtigungMinuten: Int? = null,
|
||||
val stechenGeplant: Boolean = false,
|
||||
|
||||
// Finanzen
|
||||
val startgeldCent: Long? = null,
|
||||
val geldpreisAusbezahlt: Boolean = false,
|
||||
)
|
||||
|
||||
class BewerbApi(private val apiClient: HttpClient) {
|
||||
suspend fun createBewerb(turnierId: String, payload: CreateBewerbPayload): BewerbResponse =
|
||||
apiClient.post("/turniere/$turnierId/bewerbe") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(payload)
|
||||
}.body()
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote
|
||||
|
||||
import at.mocode.frontend.core.network.*
|
||||
import at.mocode.frontend.features.turnier.data.mapper.toDomain
|
||||
import at.mocode.frontend.features.turnier.data.mapper.toDto
|
||||
import at.mocode.frontend.features.turnier.data.remote.dto.AbteilungDto
|
||||
import at.mocode.frontend.features.turnier.domain.Abteilung
|
||||
import at.mocode.frontend.features.turnier.domain.AbteilungRepository
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
class DefaultAbteilungRepository(
|
||||
private val client: HttpClient,
|
||||
) : AbteilungRepository {
|
||||
|
||||
override suspend fun list(bewerbId: Long): Result<List<Abteilung>> = runCatching {
|
||||
val response = client.get(ApiRoutes.Bewerbe.abteilungen(bewerbId))
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<List<AbteilungDto>>().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<Abteilung> = runCatching {
|
||||
val response = client.get("${ApiRoutes.API_PREFIX}/abteilungen/$id")
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<AbteilungDto>().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: Abteilung): Result<Abteilung> = runCatching {
|
||||
val response = client.post(ApiRoutes.Bewerbe.abteilungen(model.bewerbId)) { setBody(model.toDto()) }
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<AbteilungDto>().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: Abteilung): Result<Abteilung> = runCatching {
|
||||
val response = client.put("${ApiRoutes.API_PREFIX}/abteilungen/$id") { setBody(model.toDto()) }
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<AbteilungDto>().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.API_PREFIX}/abteilungen/$id")
|
||||
when {
|
||||
response.status.isSuccess() -> Unit
|
||||
response.status == HttpStatusCode.NotFound -> throw NotFound()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote
|
||||
|
||||
import at.mocode.frontend.core.network.*
|
||||
import at.mocode.frontend.features.turnier.data.mapper.toDomain
|
||||
import at.mocode.frontend.features.turnier.data.mapper.toDto
|
||||
import at.mocode.frontend.features.turnier.data.remote.dto.BewerbDto
|
||||
import at.mocode.frontend.features.turnier.domain.AuditLogEntry
|
||||
import at.mocode.frontend.features.turnier.domain.Bewerb
|
||||
import at.mocode.frontend.features.turnier.domain.BewerbRepository
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
class DefaultBewerbRepository(
|
||||
private val client: HttpClient,
|
||||
) : BewerbRepository {
|
||||
|
||||
override suspend fun list(turnierId: Long): Result<List<Bewerb>> = runCatching {
|
||||
val response = client.get(ApiRoutes.Turniere.bewerbe(turnierId))
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<List<BewerbDto>>().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 importBewerbe(turnierId: Long, bewerbe: List<at.mocode.zns.parser.ZnsBewerb>): Result<Unit> =
|
||||
runCatching {
|
||||
val response = client.post("${ApiRoutes.Turniere.bewerbe(turnierId)}/import/zns") {
|
||||
setBody(bewerbe)
|
||||
}
|
||||
when {
|
||||
response.status.isSuccess() -> Unit
|
||||
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
|
||||
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getById(id: Long): Result<Bewerb> = runCatching {
|
||||
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id")
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<BewerbDto>().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: Bewerb): Result<Bewerb> = runCatching {
|
||||
val response = client.post(ApiRoutes.Turniere.bewerbe(model.turnierId)) { setBody(model.toDto()) }
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<BewerbDto>().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: Bewerb): Result<Bewerb> = runCatching {
|
||||
val response = client.put("${ApiRoutes.API_PREFIX}/bewerbe/$id") { setBody(model.toDto()) }
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<BewerbDto>().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 updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb> =
|
||||
runCatching {
|
||||
val response = client.patch("${ApiRoutes.API_PREFIX}/bewerbe/$id/zeitplan") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
mapOf(
|
||||
"geplantesDatum" to datum,
|
||||
"beginnZeit" to beginn,
|
||||
"austragungsplatzId" to platzId
|
||||
)
|
||||
)
|
||||
}
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
|
||||
response.status == HttpStatusCode.NotFound -> throw NotFound()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>> = runCatching {
|
||||
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/audit-log")
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<List<AuditLogEntry>>()
|
||||
response.status == HttpStatusCode.NotFound -> throw NotFound()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun exportZnsBSatz(turnierId: Long): Result<String> = runCatching {
|
||||
val response = client.get("${ApiRoutes.API_PREFIX}/turniere/$turnierId/export/zns/b-satz")
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<String>()
|
||||
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
|
||||
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long): Result<Unit> = runCatching {
|
||||
val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id")
|
||||
when {
|
||||
response.status.isSuccess() -> Unit
|
||||
response.status == HttpStatusCode.NotFound -> throw NotFound()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote
|
||||
|
||||
import at.mocode.frontend.core.network.ApiRoutes
|
||||
import at.mocode.frontend.features.turnier.domain.Ergebnis
|
||||
import at.mocode.frontend.features.turnier.domain.ErgebnisRepository
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
class DefaultErgebnisRepository(
|
||||
private val client: HttpClient
|
||||
) : ErgebnisRepository {
|
||||
|
||||
override suspend fun getForBewerb(bewerbId: String): Result<List<Ergebnis>> = runCatching {
|
||||
client.get(ApiRoutes.Results.bewerb(bewerbId)).body()
|
||||
}
|
||||
|
||||
override suspend fun save(ergebnis: Ergebnis): Result<Ergebnis> = runCatching {
|
||||
if (ergebnis.id == null) {
|
||||
client.post(ApiRoutes.Results.ROOT) {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ergebnis)
|
||||
}.body()
|
||||
} else {
|
||||
client.put("${ApiRoutes.Results.ROOT}/${ergebnis.id}") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ergebnis)
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun calculatePlatzierung(bewerbId: String): Result<List<Ergebnis>> = runCatching {
|
||||
client.post("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/calculate").body()
|
||||
}
|
||||
|
||||
override suspend fun exportPdf(bewerbId: String): Result<ByteArray> = runCatching {
|
||||
client.get("${ApiRoutes.Results.ROOT}/bewerb/$bewerbId/pdf").body()
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote
|
||||
|
||||
import at.mocode.frontend.core.network.ApiRoutes
|
||||
import at.mocode.frontend.features.turnier.domain.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class DefaultMasterdataRepository(
|
||||
private val client: HttpClient
|
||||
) : MasterdataRepository {
|
||||
|
||||
override suspend fun searchReiter(query: String): Result<List<Reiter>> = runCatching {
|
||||
val response = client.get("${ApiRoutes.Masterdata.REITER}/search") {
|
||||
parameter("q", query)
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
// Wir mappen hier manuell, da die Features aktuell keine DTOs exportieren
|
||||
response.body<List<ReiterApiDto>>().map { it.toDomain() }
|
||||
} else emptyList()
|
||||
}
|
||||
|
||||
override suspend fun getReiter(id: String): Result<Reiter> = runCatching {
|
||||
val response = client.get("${ApiRoutes.Masterdata.REITER}/$id")
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<ReiterApiDto>().toDomain()
|
||||
} else throw Exception("Reiter nicht gefunden")
|
||||
}
|
||||
|
||||
override suspend fun saveReiter(reiter: Reiter): Result<Reiter> = runCatching {
|
||||
val response = client.put("${ApiRoutes.Masterdata.REITER}/${reiter.id}") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ReiterApiDto(
|
||||
reiterId = reiter.id,
|
||||
vorname = reiter.vorname,
|
||||
nachname = reiter.nachname,
|
||||
satznummer = reiter.satznummer,
|
||||
vereinsName = reiter.verein,
|
||||
feiId = reiter.feiId
|
||||
))
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<ReiterApiDto>().toDomain()
|
||||
} else throw Exception("Fehler beim Speichern des Reiters")
|
||||
}
|
||||
|
||||
override suspend fun searchPferde(query: String): Result<List<Pferd>> = runCatching {
|
||||
val response = client.get("${ApiRoutes.Masterdata.PFERDE}/search") {
|
||||
parameter("q", query)
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<List<HorseApiDto>>().map { it.toDomain() }
|
||||
} else emptyList()
|
||||
}
|
||||
|
||||
override suspend fun getPferd(id: String): Result<Pferd> = runCatching {
|
||||
val response = client.get("${ApiRoutes.Masterdata.PFERDE}/$id")
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<HorseApiDto>().toDomain()
|
||||
} else throw Exception("Pferd nicht gefunden")
|
||||
}
|
||||
|
||||
override suspend fun savePferd(pferd: Pferd): Result<Pferd> = runCatching {
|
||||
val response = client.put("${ApiRoutes.Masterdata.PFERDE}/${pferd.id}") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(HorseApiDto(
|
||||
pferdId = pferd.id,
|
||||
pferdeName = pferd.name,
|
||||
lebensnummer = pferd.lebensnummer,
|
||||
geschlecht = "UNBEKANNT", // Fallback
|
||||
geburtsjahr = pferd.geburtsjahr,
|
||||
satznummer = pferd.oepsNummer
|
||||
))
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<HorseApiDto>().toDomain()
|
||||
} else throw Exception("Fehler beim Speichern des Pferdes")
|
||||
}
|
||||
|
||||
override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = runCatching {
|
||||
val response = client.get("${ApiRoutes.Masterdata.FUNKTIONAERE}/search") {
|
||||
parameter("q", query)
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<List<FunktionaerApiDto>>().map {
|
||||
Funktionaer(it.funktionaerId, it.name ?: "Unbekannt", it.qualifikationen, it.istAktiv)
|
||||
}
|
||||
} else emptyList()
|
||||
}
|
||||
|
||||
override suspend fun listVereine(): Result<List<Verein>> = runCatching {
|
||||
val response = client.get(ApiRoutes.Masterdata.VEREINE)
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<List<VereinApiDto>>().map {
|
||||
Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter)
|
||||
}
|
||||
} else emptyList()
|
||||
}
|
||||
|
||||
override suspend fun getVereinById(id: String): Result<Verein> = runCatching {
|
||||
val response = client.get("${ApiRoutes.Masterdata.VEREINE}/$id")
|
||||
if (response.status.isSuccess()) {
|
||||
val it = response.body<VereinApiDto>()
|
||||
Verein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.istVeranstalter)
|
||||
} else throw Exception("Verein nicht gefunden")
|
||||
}
|
||||
|
||||
// Interne Hilfs-DTOs für das Mapping der Masterdata-API
|
||||
@Serializable
|
||||
private data class ReiterApiDto(
|
||||
val reiterId: String,
|
||||
val vorname: String,
|
||||
val nachname: String,
|
||||
val satznummer: String? = null,
|
||||
val vereinsName: String? = null,
|
||||
val feiId: String? = null,
|
||||
val reiterLizenz: String? = null
|
||||
) {
|
||||
fun toDomain() = Reiter(
|
||||
id = reiterId,
|
||||
vorname = vorname,
|
||||
nachname = nachname,
|
||||
satznummer = satznummer,
|
||||
verein = vereinsName,
|
||||
feiId = feiId,
|
||||
oepsNummer = satznummer
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class HorseApiDto(
|
||||
val pferdId: String,
|
||||
val pferdeName: String,
|
||||
val lebensnummer: String? = null,
|
||||
val geschlecht: String,
|
||||
val geburtsjahr: Int? = null,
|
||||
val satznummer: String? = null
|
||||
) {
|
||||
fun toDomain() = Pferd(
|
||||
id = pferdId,
|
||||
name = pferdeName,
|
||||
lebensnummer = lebensnummer ?: "",
|
||||
geburtsjahr = geburtsjahr,
|
||||
oepsNummer = satznummer
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class FunktionaerApiDto(
|
||||
val funktionaerId: String,
|
||||
val name: String? = null,
|
||||
val qualifikationen: List<String> = emptyList(),
|
||||
val istAktiv: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class VereinApiDto(
|
||||
val vereinId: String,
|
||||
val vereinsNummer: String,
|
||||
val name: String,
|
||||
val ort: String? = null,
|
||||
val istVeranstalter: Boolean
|
||||
)
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote
|
||||
|
||||
import at.mocode.frontend.core.network.*
|
||||
import at.mocode.frontend.features.turnier.data.remote.dto.*
|
||||
import at.mocode.frontend.features.turnier.domain.Nennung
|
||||
import at.mocode.frontend.features.turnier.domain.NennungRepository
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
class DefaultNennungRepository(
|
||||
private val client: HttpClient
|
||||
) : NennungRepository {
|
||||
|
||||
override suspend fun list(turnierId: Long): Result<List<Nennung>> = runCatching {
|
||||
val response = client.get("${ApiRoutes.Turniere.ROOT}/$turnierId/nennungen")
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<List<NennungSummaryDto>>().map { it.toDomain() }
|
||||
} else {
|
||||
throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>> = runCatching {
|
||||
val response = client.get(ApiRoutes.Bewerbe.nennungen(bewerbId))
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<List<NennungSummaryDto>>().map { it.toDomain() }
|
||||
} else {
|
||||
throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun einreichen(request: NennungEinreichenRequest): Result<Nennung> = runCatching {
|
||||
val response = client.post(ApiRoutes.Bewerbe.nennungen(request.bewerbId.toLong())) {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<NennungDetailDto>().toDomain()
|
||||
} else {
|
||||
throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateStatus(id: String, status: String): Result<Nennung> = runCatching {
|
||||
val response = client.patch("${ApiRoutes.API_PREFIX}/nennungen/$id/status") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(mapOf("status" to status))
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
response.body<NennungDetailDto>().toDomain()
|
||||
} else {
|
||||
throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: String): Result<Unit> = runCatching {
|
||||
val response = client.delete("${ApiRoutes.API_PREFIX}/nennungen/$id")
|
||||
if (!response.status.isSuccess()) {
|
||||
throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NennungSummaryDto.toDomain() = Nennung(
|
||||
id = nennungId,
|
||||
turnierId = turnierId,
|
||||
bewerbId = bewerbId,
|
||||
abteilungId = abteilungId,
|
||||
reiterId = reiterId,
|
||||
pferdId = pferdId,
|
||||
status = status,
|
||||
istNachnennung = istNachnennung,
|
||||
createdAt = createdAt
|
||||
)
|
||||
|
||||
private fun NennungDetailDto.toDomain() = Nennung(
|
||||
id = nennungId,
|
||||
turnierId = turnierId,
|
||||
bewerbId = bewerbId,
|
||||
abteilungId = abteilungId,
|
||||
reiterId = reiterId,
|
||||
pferdId = pferdId,
|
||||
status = status,
|
||||
istNachnennung = istNachnennung,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote
|
||||
|
||||
import at.mocode.frontend.core.network.ApiRoutes
|
||||
import at.mocode.frontend.features.turnier.domain.Serie
|
||||
import at.mocode.frontend.features.turnier.domain.SerieStandEntry
|
||||
import at.mocode.frontend.features.turnier.domain.SeriesRepository
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
class DefaultSeriesRepository(
|
||||
private val client: HttpClient
|
||||
) : SeriesRepository {
|
||||
|
||||
override suspend fun getAll(): Result<List<Serie>> = runCatching {
|
||||
client.get(ApiRoutes.Series.ROOT).body()
|
||||
}
|
||||
|
||||
override suspend fun getById(id: String): Result<Serie> = runCatching {
|
||||
client.get("${ApiRoutes.Series.ROOT}/$id").body()
|
||||
}
|
||||
|
||||
override suspend fun save(serie: Serie): Result<Serie> = runCatching {
|
||||
if (serie.id == null) {
|
||||
client.post(ApiRoutes.Series.ROOT) {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(serie)
|
||||
}.body()
|
||||
} else {
|
||||
client.put("${ApiRoutes.Series.ROOT}/${serie.id}") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(serie)
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getStand(serieId: String): Result<List<SerieStandEntry>> = runCatching {
|
||||
client.get(ApiRoutes.Series.stand(serieId)).body()
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote
|
||||
|
||||
import at.mocode.frontend.core.network.*
|
||||
import at.mocode.frontend.features.turnier.domain.StartlistenRepository
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile
|
||||
|
||||
class DefaultStartlistenRepository(
|
||||
private val client: HttpClient,
|
||||
) : StartlistenRepository {
|
||||
|
||||
override suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>> = runCatching {
|
||||
val response = client.post("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/startliste/generate")
|
||||
when {
|
||||
response.status.isSuccess() -> response.body()
|
||||
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
|
||||
response.status.value >= 500 -> throw ServerError()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>> = runCatching {
|
||||
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$bewerbId/startliste")
|
||||
when {
|
||||
response.status.isSuccess() -> response.body()
|
||||
response.status == HttpStatusCode.NotFound -> emptyList()
|
||||
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
|
||||
else -> throw HttpError(response.status.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote
|
||||
|
||||
import at.mocode.frontend.core.network.*
|
||||
import at.mocode.frontend.features.turnier.data.mapper.toDomain
|
||||
import at.mocode.frontend.features.turnier.data.mapper.toDto
|
||||
import at.mocode.frontend.features.turnier.data.remote.dto.TurnierDto
|
||||
import at.mocode.frontend.features.turnier.domain.Turnier
|
||||
import at.mocode.frontend.features.turnier.domain.TurnierRepository
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
class DefaultTurnierRepository(
|
||||
private val client: HttpClient,
|
||||
) : TurnierRepository {
|
||||
|
||||
override suspend fun list(): Result<List<Turnier>> = runCatching {
|
||||
val response = client.get(ApiRoutes.Turniere.ROOT)
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<List<TurnierDto>>().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<Turnier> = runCatching {
|
||||
val response = client.get("${ApiRoutes.Turniere.ROOT}/$id")
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<TurnierDto>().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: Turnier): Result<Turnier> = runCatching {
|
||||
val response = client.post(ApiRoutes.Turniere.ROOT) { setBody(model.toDto()) }
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<TurnierDto>().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: Turnier): Result<Turnier> = runCatching {
|
||||
val response = client.put("${ApiRoutes.Turniere.ROOT}/$id") { setBody(model.toDto()) }
|
||||
when {
|
||||
response.status.isSuccess() -> response.body<TurnierDto>().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.Turniere.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@Serializable
|
||||
data class NennungSummaryDto(
|
||||
val nennungId: String,
|
||||
val turnierId: String,
|
||||
val bewerbId: String,
|
||||
val abteilungId: String,
|
||||
val reiterId: String,
|
||||
val pferdId: String,
|
||||
val status: String,
|
||||
val istNachnennung: Boolean,
|
||||
val createdAt: String
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@Serializable
|
||||
data class NennungDetailDto(
|
||||
val nennungId: String,
|
||||
val abteilungId: String,
|
||||
val bewerbId: String,
|
||||
val turnierId: String,
|
||||
val reiterId: String,
|
||||
val pferdId: String,
|
||||
val zahlerId: String? = null,
|
||||
val status: String,
|
||||
val startwunsch: String,
|
||||
val istNachnennung: Boolean,
|
||||
val bemerkungen: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NennungEinreichenRequest(
|
||||
val abteilungId: String,
|
||||
val bewerbId: String,
|
||||
val turnierId: String,
|
||||
val reiterId: String,
|
||||
val pferdId: String,
|
||||
val zahlerId: String? = null,
|
||||
val startwunsch: String = "KEIN_WUNSCH",
|
||||
val istNachnennung: Boolean = false,
|
||||
val bemerkungen: String? = null
|
||||
)
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package at.mocode.frontend.features.turnier.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TurnierDto(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BewerbDto(
|
||||
val id: Long,
|
||||
val turnierId: Long,
|
||||
val tag: String,
|
||||
val platz: Int,
|
||||
val name: String,
|
||||
val sparte: String,
|
||||
val klasse: String,
|
||||
val nennungen: Int,
|
||||
val geplantesDatum: String? = null,
|
||||
val beginnZeit: String? = null,
|
||||
val reitdauerMinuten: Int? = null,
|
||||
val umbauMinuten: Int? = null,
|
||||
val besichtigungMinuten: Int? = null,
|
||||
val austragungsplatzId: String? = null,
|
||||
val warnungen: List<AbteilungsWarnungDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbteilungsWarnungDto(
|
||||
val code: String,
|
||||
val nachricht: String,
|
||||
val oetoParagraph: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbteilungDto(
|
||||
val id: Long,
|
||||
val bewerbId: Long,
|
||||
val name: String,
|
||||
)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package at.mocode.frontend.features.turnier.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>
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package at.mocode.frontend.features.turnier.domain
|
||||
|
||||
import at.mocode.zns.parser.ZnsBewerb
|
||||
|
||||
data class Bewerb(
|
||||
val id: Long,
|
||||
val turnierId: Long,
|
||||
val tag: String,
|
||||
val platz: Int,
|
||||
val name: String,
|
||||
val sparte: String,
|
||||
val klasse: String,
|
||||
val nennungen: Int,
|
||||
val warnungen: List<AbteilungsWarnung> = emptyList(),
|
||||
// Zeitplan-Felder
|
||||
val geplantesDatum: String? = null, // ISO-Format
|
||||
val beginnZeit: String? = null, // "HH:mm"
|
||||
val reitdauerMinuten: Int? = null,
|
||||
val umbauMinuten: Int? = null,
|
||||
val besichtigungMinuten: Int? = null,
|
||||
val austragungsplatzId: String? = null,
|
||||
)
|
||||
|
||||
data class AbteilungsWarnung(
|
||||
val code: String,
|
||||
val nachricht: String,
|
||||
val oetoParagraph: String?
|
||||
)
|
||||
|
||||
data class AuditLogEntry(
|
||||
val id: String,
|
||||
val entityType: String,
|
||||
val entityId: String,
|
||||
val action: String,
|
||||
val userId: String?,
|
||||
val timestamp: String,
|
||||
val changesJson: String?
|
||||
)
|
||||
|
||||
interface BewerbRepository {
|
||||
suspend fun list(turnierId: Long): Result<List<Bewerb>>
|
||||
suspend fun getById(id: Long): Result<Bewerb>
|
||||
suspend fun create(model: Bewerb): Result<Bewerb>
|
||||
suspend fun update(id: Long, model: Bewerb): Result<Bewerb>
|
||||
suspend fun updateZeitplan(id: Long, datum: String?, beginn: String?, platzId: String?): Result<Bewerb>
|
||||
suspend fun getAuditLog(bewerbId: Long): Result<List<AuditLogEntry>>
|
||||
suspend fun exportZnsBSatz(turnierId: Long): Result<String>
|
||||
suspend fun delete(id: Long): Result<Unit>
|
||||
suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit>
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package at.mocode.frontend.features.turnier.domain
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Ergebnis(
|
||||
val id: String? = null,
|
||||
val nennungId: String,
|
||||
val bewerbId: String,
|
||||
val wertnote: Double? = null,
|
||||
val zeit: Double? = null,
|
||||
val fehler: Double? = null,
|
||||
val status: ErgebnisStatus = ErgebnisStatus.OK,
|
||||
val platzierung: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class ErgebnisStatus {
|
||||
OK, AUSGESCHIEDEN, VERZICHTET, DISQUALIFIZIERT, NICHT_GESTARTET
|
||||
}
|
||||
|
||||
interface ErgebnisRepository {
|
||||
suspend fun getForBewerb(bewerbId: String): Result<List<Ergebnis>>
|
||||
suspend fun save(ergebnis: Ergebnis): Result<Ergebnis>
|
||||
suspend fun calculatePlatzierung(bewerbId: String): Result<List<Ergebnis>>
|
||||
suspend fun exportPdf(bewerbId: String): Result<ByteArray>
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package at.mocode.frontend.features.turnier.domain
|
||||
|
||||
data class Reiter(
|
||||
val id: String,
|
||||
val vorname: String,
|
||||
val nachname: String,
|
||||
val satznummer: String? = null,
|
||||
val verein: String? = null,
|
||||
val feiId: String? = null,
|
||||
val oepsNummer: String? = null
|
||||
) {
|
||||
val name: String get() = "$vorname $nachname"
|
||||
}
|
||||
|
||||
data class Pferd(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val lebensnummer: String,
|
||||
val geburtsjahr: Int? = null,
|
||||
val oepsNummer: String? = null
|
||||
)
|
||||
|
||||
data class Funktionaer(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val qualifikationen: List<String>,
|
||||
val istAktiv: Boolean
|
||||
)
|
||||
|
||||
data class Verein(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val vereinsNummer: String,
|
||||
val ort: String?,
|
||||
val istVeranstalter: Boolean
|
||||
)
|
||||
|
||||
interface MasterdataRepository {
|
||||
suspend fun searchReiter(query: String): Result<List<Reiter>>
|
||||
suspend fun getReiter(id: String): Result<Reiter>
|
||||
suspend fun saveReiter(reiter: Reiter): Result<Reiter>
|
||||
|
||||
suspend fun searchPferde(query: String): Result<List<Pferd>>
|
||||
suspend fun getPferd(id: String): Result<Pferd>
|
||||
suspend fun savePferd(pferd: Pferd): Result<Pferd>
|
||||
|
||||
suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>>
|
||||
suspend fun listVereine(): Result<List<Verein>>
|
||||
suspend fun getVereinById(id: String): Result<Verein>
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package at.mocode.frontend.features.turnier.domain
|
||||
|
||||
data class Nennung(
|
||||
val id: String,
|
||||
val turnierId: String,
|
||||
val bewerbId: String,
|
||||
val abteilungId: String,
|
||||
val reiterId: String,
|
||||
val pferdId: String,
|
||||
val status: String,
|
||||
val istNachnennung: Boolean,
|
||||
val createdAt: String,
|
||||
// Erweiterte Infos für UI
|
||||
val reiterName: String? = null,
|
||||
val pferdeName: String? = null,
|
||||
val bewerbName: String? = null
|
||||
)
|
||||
|
||||
interface NennungRepository {
|
||||
suspend fun list(turnierId: Long): Result<List<Nennung>>
|
||||
suspend fun listByBewerb(bewerbId: Long): Result<List<Nennung>>
|
||||
suspend fun einreichen(request: at.mocode.frontend.features.turnier.data.remote.dto.NennungEinreichenRequest): Result<Nennung>
|
||||
suspend fun updateStatus(id: String, status: String): Result<Nennung>
|
||||
suspend fun delete(id: String): Result<Unit>
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package at.mocode.frontend.features.turnier.domain
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Serie(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val beschreibung: String? = null,
|
||||
val reglementTyp: String = "STREICHER_NORMAL",
|
||||
val streichresultateCount: Int = 1,
|
||||
val bindungstyp: String = "PAAR_BINDUNG",
|
||||
val bewerbIds: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SerieStandEntry(
|
||||
val reiterId: String,
|
||||
val pferdId: String?,
|
||||
val punkte: Double,
|
||||
val anzahlWertungen: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeriePunkt(
|
||||
val id: String? = null,
|
||||
val serieId: String,
|
||||
val reiterId: String,
|
||||
val pferdId: String,
|
||||
val bewerbId: String,
|
||||
val punkte: Double,
|
||||
val platzierung: Int
|
||||
)
|
||||
|
||||
interface SeriesRepository {
|
||||
suspend fun getAll(): Result<List<Serie>>
|
||||
suspend fun getById(id: String): Result<Serie>
|
||||
suspend fun save(serie: Serie): Result<Serie>
|
||||
suspend fun getStand(serieId: String): Result<List<SerieStandEntry>>
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package at.mocode.frontend.features.turnier.domain
|
||||
|
||||
import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile
|
||||
|
||||
interface StartlistenRepository {
|
||||
suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>>
|
||||
suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>>
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package at.mocode.frontend.features.turnier.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>
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package at.mocode.frontend.features.turnier.domain.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class StartlistenZeile(
|
||||
val nr: Int,
|
||||
val zeit: String,
|
||||
val reiter: String,
|
||||
val pferd: String,
|
||||
val wunsch: String,
|
||||
val nennungId: String = ""
|
||||
)
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package at.mocode.frontend.features.turnier.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)
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
|
||||
/**
|
||||
* Placeholder-Screens für Akteur-Verwaltung (actor-context).
|
||||
* Werden in Phase 4/5 mit echten Daten aus dem actor-context befüllt.
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun ReiterScreen() {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||
Text("Reiter", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
PlaceholderContent(
|
||||
title = "Reiter-Verwaltung",
|
||||
subtitle = "Satznummer, Lizenzklasse, Sparten-Lizenz – actor-context (Phase 4).",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PferdeScreen() {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||
Text("Pferde", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
PlaceholderContent(
|
||||
title = "Pferde-Verwaltung",
|
||||
subtitle = "Lebensnummer, ZNS-Daten, Passbesitzer – actor-context (Phase 4).",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FunktionaereScreen() {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||
Text("Funktionäre", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
PlaceholderContent(
|
||||
title = "Funktionäre-Verwaltung",
|
||||
subtitle = "Richter, Parcourschef, Tierarzt – actor-context (Phase 4).",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MeisterschaftenScreen() {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||
Text("Meisterschaften", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
PlaceholderContent(
|
||||
title = "Meisterschaften",
|
||||
subtitle = "Konfigurierbare Reglements, Punktesysteme – series-context (Phase 2+).",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CupsScreen() {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||
Text("Cups", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
PlaceholderContent(
|
||||
title = "Cups & Serien",
|
||||
subtitle = "Pluggable Berechnungsmodell, Paar-Bindung – series-context (Phase 2+).",
|
||||
)
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
// Abteilungs-Typen gemäß Domain
|
||||
enum class AbteilungsTyp {
|
||||
SEPARATE_SIEGEREHRUNG,
|
||||
ORGANISATORISCH,
|
||||
}
|
||||
|
||||
// Rider-Klasse für Vorschlagslogik (vereinfachtes Modell)
|
||||
enum class ReiterKlasse { R1, R2_PLUS }
|
||||
|
||||
data class AbteilungsInput(
|
||||
val id: Int,
|
||||
val label: String,
|
||||
val mitLizenz: Boolean,
|
||||
val reiterKlasse: ReiterKlasse,
|
||||
)
|
||||
|
||||
data class BewerbAnlegenState(
|
||||
val isOpen: Boolean = false,
|
||||
val bewerbsTyp: String = "",
|
||||
val abteilungsTyp: AbteilungsTyp = AbteilungsTyp.SEPARATE_SIEGEREHRUNG,
|
||||
val abteilungen: List<AbteilungsInput> = emptyList(),
|
||||
)
|
||||
|
||||
sealed interface BewerbAnlegenIntent {
|
||||
data object Open : BewerbAnlegenIntent
|
||||
data object Close : BewerbAnlegenIntent
|
||||
data class SetBewerbsTyp(val typ: String) : BewerbAnlegenIntent
|
||||
data class SetAbteilungsTyp(val typ: AbteilungsTyp) : BewerbAnlegenIntent
|
||||
data object ApplyAutoSuggestionIfNeeded : BewerbAnlegenIntent
|
||||
}
|
||||
|
||||
class BewerbAnlegenViewModel {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _state = MutableStateFlow(BewerbAnlegenState())
|
||||
val state: StateFlow<BewerbAnlegenState> = _state
|
||||
|
||||
fun send(intent: BewerbAnlegenIntent) {
|
||||
when (intent) {
|
||||
is BewerbAnlegenIntent.Open -> reduce { it.copy(isOpen = true) }
|
||||
is BewerbAnlegenIntent.Close -> reduce { BewerbAnlegenState() }
|
||||
is BewerbAnlegenIntent.SetBewerbsTyp -> reduce { it.copy(bewerbsTyp = intent.typ) }.also {
|
||||
// Bei Änderung des Typs gleich prüfen, ob Auto-Vorschlag anzuwenden ist
|
||||
send(BewerbAnlegenIntent.ApplyAutoSuggestionIfNeeded)
|
||||
}
|
||||
is BewerbAnlegenIntent.SetAbteilungsTyp -> reduce { it.copy(abteilungsTyp = intent.typ) }
|
||||
is BewerbAnlegenIntent.ApplyAutoSuggestionIfNeeded -> applySuggestion()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySuggestion() {
|
||||
val s = _state.value
|
||||
val bTyp = s.bewerbsTyp.uppercase()
|
||||
|
||||
val suggestion = when {
|
||||
bTyp.contains("CSN-C-NEU") -> listOf(
|
||||
AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1),
|
||||
AbteilungsInput(2, label = "Abteilung 2: R2+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS),
|
||||
)
|
||||
bTyp.contains("CDN-B") || bTyp.contains("CDNP-B") -> listOf(
|
||||
AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1),
|
||||
AbteilungsInput(2, label = "Abteilung 2: R2", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS),
|
||||
AbteilungsInput(3, label = "Abteilung 3: R3+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS),
|
||||
)
|
||||
bTyp.contains("CSN-B") -> listOf(
|
||||
AbteilungsInput(1, label = "Abteilung 1: R1", mitLizenz = true, reiterKlasse = ReiterKlasse.R1),
|
||||
AbteilungsInput(2, label = "Abteilung 2: R2+", mitLizenz = true, reiterKlasse = ReiterKlasse.R2_PLUS),
|
||||
)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
if (suggestion.isNotEmpty()) {
|
||||
reduce { it.copy(abteilungen = suggestion, abteilungsTyp = AbteilungsTyp.SEPARATE_SIEGEREHRUNG) }
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun reduce(block: (BewerbAnlegenState) -> BewerbAnlegenState) {
|
||||
_state.value = block(_state.value)
|
||||
}
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import at.mocode.frontend.core.network.discovery.DiscoveredService
|
||||
import at.mocode.frontend.core.network.sync.DataChangedEvent
|
||||
import at.mocode.frontend.core.network.sync.PingEvent
|
||||
import at.mocode.frontend.core.network.sync.SyncManager
|
||||
import at.mocode.frontend.features.turnier.domain.Bewerb
|
||||
import at.mocode.frontend.features.turnier.domain.BewerbRepository
|
||||
import at.mocode.frontend.features.turnier.domain.StartlistenRepository
|
||||
import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile
|
||||
import at.mocode.zns.parser.ZnsBewerb
|
||||
import at.mocode.zns.parser.ZnsBewerbParser
|
||||
import at.mocode.zns.parser.ZnsNennung
|
||||
import at.mocode.zns.parser.ZnsNennungParser
|
||||
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.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
typealias BewerbListItem = Bewerb
|
||||
|
||||
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,
|
||||
val importPreview: List<ZnsBewerb> = emptyList(),
|
||||
val nennungenPreview: List<ZnsNennung> = emptyList(),
|
||||
val showImportDialog: Boolean = false,
|
||||
val showStartlistePreview: Boolean = false,
|
||||
val currentStartliste: List<StartlistenZeile> = emptyList(),
|
||||
val discoveredNodes: List<DiscoveredService> = emptyList(),
|
||||
val isScanning: Boolean = false,
|
||||
// Zeitplan-Audit
|
||||
val auditLog: List<at.mocode.frontend.features.turnier.domain.AuditLogEntry> = emptyList(),
|
||||
val isAuditLoading: Boolean = false,
|
||||
val exportContent: String? = null,
|
||||
val showExportDialog: Boolean = false,
|
||||
val ergebnisse: List<at.mocode.frontend.features.turnier.domain.Ergebnis> = emptyList(),
|
||||
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
|
||||
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
|
||||
val editingErgebnis: at.mocode.frontend.features.turnier.domain.Ergebnis? = null,
|
||||
val selectedZeile: StartlistenZeile? = null
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
data object OpenImportDialog : BewerbIntent
|
||||
data object CloseImportDialog : BewerbIntent
|
||||
data class ProcessImportFile(val lines: List<String>) : BewerbIntent
|
||||
data class ConfirmImport(val turnierId: Long) : BewerbIntent
|
||||
data object GenerateStartliste : BewerbIntent
|
||||
data object CloseStartlistePreview : BewerbIntent
|
||||
data object StartNetworkScan : BewerbIntent
|
||||
data object StopNetworkScan : BewerbIntent
|
||||
data object RefreshDiscoveredNodes : BewerbIntent
|
||||
data class UpdateZeitplan(val id: Long, val beginnZeit: String?) : BewerbIntent
|
||||
data class LoadAuditLog(val bewerbId: Long) : BewerbIntent
|
||||
data object ExportZnsBSatz : BewerbIntent
|
||||
data object CloseExportDialog : BewerbIntent
|
||||
data object LoadErgebnisse : BewerbIntent
|
||||
data class OpenErgebnisEdit(val zeile: StartlistenZeile) : BewerbIntent
|
||||
data object CloseErgebnisEdit : BewerbIntent
|
||||
data class SaveErgebnis(val ergebnis: at.mocode.frontend.features.turnier.domain.Ergebnis) : BewerbIntent
|
||||
data object CalculatePlatzierung : BewerbIntent
|
||||
data object ExportErgebnislistePdf : BewerbIntent
|
||||
}
|
||||
|
||||
|
||||
class BewerbViewModel(
|
||||
private val repo: BewerbRepository,
|
||||
private val startlistenRepo: StartlistenRepository,
|
||||
private val ergebnisRepo: at.mocode.frontend.features.turnier.domain.ErgebnisRepository,
|
||||
private val syncManager: SyncManager? = null,
|
||||
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)
|
||||
observeSyncEvents()
|
||||
}
|
||||
|
||||
private fun observeSyncEvents() {
|
||||
syncManager?.let { manager ->
|
||||
scope.launch {
|
||||
manager.getIncomingEvents().collect { event ->
|
||||
when (event) {
|
||||
is DataChangedEvent -> {
|
||||
if (event.aggregateType == "Bewerb" || event.aggregateType == "Startliste") {
|
||||
load() // Bei relevanten Änderungen neu laden
|
||||
}
|
||||
}
|
||||
|
||||
is PingEvent -> {
|
||||
// Optional: Heartbeat loggen oder Status anzeigen
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auch verbundene Peers beobachten
|
||||
scope.launch {
|
||||
manager.getConnectedPeers().collect { peers ->
|
||||
reduce {
|
||||
it.copy(discoveredNodes = peers.map { p ->
|
||||
DiscoveredService("P2P", p, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
if (intent.id != null) {
|
||||
loadErgebnisse()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
is BewerbIntent.OpenImportDialog -> _state.value = _state.value.copy(showImportDialog = true)
|
||||
is BewerbIntent.CloseImportDialog -> _state.value =
|
||||
_state.value.copy(showImportDialog = false, importPreview = emptyList(), nennungenPreview = emptyList())
|
||||
|
||||
is BewerbIntent.ProcessImportFile -> {
|
||||
val bewerbe = intent.lines.mapNotNull { ZnsBewerbParser.parse(it) }
|
||||
val nennungen = intent.lines.mapNotNull { ZnsNennungParser.parse(it) }
|
||||
_state.value = _state.value.copy(importPreview = bewerbe, nennungenPreview = nennungen)
|
||||
}
|
||||
|
||||
is BewerbIntent.ConfirmImport -> {
|
||||
confirmImport()
|
||||
}
|
||||
|
||||
is BewerbIntent.GenerateStartliste -> generateStartliste()
|
||||
is BewerbIntent.CloseStartlistePreview -> reduce { it.copy(showStartlistePreview = false) }
|
||||
is BewerbIntent.StartNetworkScan -> startScan()
|
||||
is BewerbIntent.StopNetworkScan -> stopScan()
|
||||
is BewerbIntent.RefreshDiscoveredNodes -> refreshNodes()
|
||||
is BewerbIntent.UpdateZeitplan -> updateZeitplan(intent.id, intent.beginnZeit)
|
||||
is BewerbIntent.LoadAuditLog -> loadAuditLog(intent.bewerbId)
|
||||
is BewerbIntent.ExportZnsBSatz -> exportZnsBSatz()
|
||||
is BewerbIntent.CloseExportDialog -> reduce { it.copy(showExportDialog = false, exportContent = null) }
|
||||
is BewerbIntent.LoadErgebnisse -> loadErgebnisse()
|
||||
is BewerbIntent.OpenErgebnisEdit -> {
|
||||
val bewerbId = state.value.selectedId?.toString() ?: ""
|
||||
reduce {
|
||||
it.copy(
|
||||
selectedZeile = intent.zeile,
|
||||
editingErgebnis = at.mocode.frontend.features.turnier.domain.Ergebnis(
|
||||
nennungId = intent.zeile.nennungId,
|
||||
bewerbId = bewerbId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is BewerbIntent.CloseErgebnisEdit -> reduce { it.copy(editingErgebnis = null, selectedZeile = null) }
|
||||
is BewerbIntent.SaveErgebnis -> {
|
||||
scope.launch {
|
||||
ergebnisRepo.save(intent.ergebnis).onSuccess {
|
||||
reduce { it.copy(editingErgebnis = null, selectedZeile = null) }
|
||||
loadErgebnisse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is BewerbIntent.CalculatePlatzierung -> {
|
||||
val selectedId = state.value.selectedId ?: return
|
||||
scope.launch {
|
||||
ergebnisRepo.calculatePlatzierung(selectedId.toString()).onSuccess {
|
||||
loadErgebnisse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is BewerbIntent.ExportErgebnislistePdf -> {
|
||||
val selectedId = state.value.selectedId ?: return
|
||||
scope.launch {
|
||||
ergebnisRepo.exportPdf(selectedId.toString()).onSuccess { bytes ->
|
||||
// In einer echten Desktop-App würde man hier einen File-Saver öffnen.
|
||||
// Für den MVP loggen wir nur den Erfolg ein.
|
||||
println("PDF Export erfolgreich: ${bytes.size} bytes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadErgebnisse() {
|
||||
val bewerbId = state.value.selectedId ?: return
|
||||
scope.launch {
|
||||
ergebnisRepo.getForBewerb(bewerbId.toString()).onSuccess { list ->
|
||||
reduce { it.copy(ergebnisse = list) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportZnsBSatz() {
|
||||
_state.update { it.copy(isLoading = true) }
|
||||
scope.launch {
|
||||
repo.exportZnsBSatz(turnierId).onSuccess { content ->
|
||||
_state.update { it.copy(isLoading = false, showExportDialog = true, exportContent = content) }
|
||||
}.onFailure { t ->
|
||||
_state.update { it.copy(isLoading = false, errorMessage = "ZNS-Export fehlgeschlagen: ${t.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAuditLog(id: Long) {
|
||||
_state.update { it.copy(isAuditLoading = true) }
|
||||
scope.launch {
|
||||
repo.getAuditLog(id).onSuccess { log ->
|
||||
_state.update { it.copy(auditLog = log, isAuditLoading = false) }
|
||||
}.onFailure { t ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
isAuditLoading = false,
|
||||
errorMessage = "Audit-Log konnte nicht geladen werden: ${t.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateZeitplan(id: Long, beginn: String?) {
|
||||
scope.launch {
|
||||
repo.updateZeitplan(id, null, beginn, null).onSuccess {
|
||||
load() // Neu laden, um Konsistenz zu prüfen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startScan() {
|
||||
syncManager?.start(8080)
|
||||
_state.update { it.copy(isScanning = true) }
|
||||
// Nach dem Start des Servers ein ConnectivityCheck-Event Broadcasting, um Präsenz zu zeigen
|
||||
syncManager?.broadcastEvent(
|
||||
PingEvent(
|
||||
eventId = turnierId.toString(),
|
||||
sequenceNumber = 0,
|
||||
originNodeId = "Client-${(1000..9999).random()}",
|
||||
createdAt = 0 // In commonMain ohne Clock-Lib erst mal 0
|
||||
)
|
||||
)
|
||||
refreshNodes()
|
||||
}
|
||||
|
||||
private fun stopScan() {
|
||||
syncManager?.stop()
|
||||
_state.update { it.copy(isScanning = false) }
|
||||
}
|
||||
|
||||
private fun refreshNodes() {
|
||||
// Da wir jetzt den SyncManager nutzen, könnten wir hier die connectedPeers anzeigen
|
||||
// oder weiterhin die Entdeckten aus dem internen DiscoveryService des Managers.
|
||||
// Für dieses MVP zeigen wir einfach an, dass wir scannen.
|
||||
}
|
||||
|
||||
fun generateStartliste() {
|
||||
val selectedId = _state.value.selectedId ?: return
|
||||
reduce { it.copy(isLoading = true) }
|
||||
|
||||
scope.launch {
|
||||
startlistenRepo.generate(selectedId).onSuccess { list ->
|
||||
reduce {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
showStartlistePreview = true,
|
||||
currentStartliste = list
|
||||
)
|
||||
}
|
||||
}.onFailure { t ->
|
||||
reduce { it.copy(isLoading = false, errorMessage = "Startlisten-Generierung fehlgeschlagen: ${t.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmImport() {
|
||||
val toImport = _state.value.importPreview
|
||||
if (toImport.isEmpty()) {
|
||||
_state.value = _state.value.copy(showImportDialog = false)
|
||||
return
|
||||
}
|
||||
|
||||
reduce { it.copy(isLoading = true) }
|
||||
scope.launch {
|
||||
val result = repo.importBewerbe(turnierId, toImport)
|
||||
if (result.isSuccess) {
|
||||
reduce { it.copy(showImportDialog = false, importPreview = emptyList()) }
|
||||
load()
|
||||
} else {
|
||||
reduce {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Import fehlgeschlagen: ${result.exceptionOrNull()?.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
reduce { it.copy(isLoading = true, errorMessage = null) }
|
||||
scope.launch {
|
||||
repo.list(turnierId).onSuccess { items ->
|
||||
reduce { cur ->
|
||||
val filtered = filterList(items, cur.searchQuery)
|
||||
cur.copy(isLoading = false, list = items, filtered = filtered)
|
||||
}
|
||||
}.onFailure { t ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.features.turnier.data.remote.CreateBewerbPayload
|
||||
import at.mocode.frontend.features.turnier.data.remote.RichterEinsatzDto
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
|
||||
enum class WizardStep { IDENTIFIKATION, DETAILS_FINANZEN, ORT_ZEIT, RICHTER_TEILUNG }
|
||||
|
||||
data class CreateBewerbWizardState(
|
||||
// Step 1
|
||||
val klasse: String = "",
|
||||
val hoeheCm: String = "", // UI-Text, wird zu Int? geparst
|
||||
val bezeichnung: String = "",
|
||||
|
||||
// Step 2
|
||||
val beschreibung: String = "",
|
||||
val aufgabe: String = "",
|
||||
val startgeld: String = "", // UI-Text, wird zu Long? Cent
|
||||
val geldpreisAusbezahlt: Boolean = false,
|
||||
|
||||
// Step 3
|
||||
val austragungsplatzId: String = "",
|
||||
val beginnZeitTyp: String = "", // FIX / ANSCHLIESSEND
|
||||
val geplantesDatum: String = "", // yyyy-MM-dd
|
||||
val beginnZeit: String = "", // HH:mm
|
||||
val reitdauerMinuten: String = "",
|
||||
val umbauMinuten: String = "",
|
||||
val besichtigungMinuten: String = "",
|
||||
val stechenGeplant: Boolean = false,
|
||||
|
||||
// Step 4
|
||||
val richter: List<RichterEinsatzDto> = emptyList(),
|
||||
val teilungsTyp: String = "", // Hinweis: aktuell nur UI; Backend-Feld folgt separat
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreateBewerbWizardScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
state: CreateBewerbWizardState,
|
||||
onStateChange: (CreateBewerbWizardState) -> Unit,
|
||||
onSubmit: (CreateBewerbPayload) -> Unit,
|
||||
) {
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
val steps = WizardStep.entries.toTypedArray()
|
||||
|
||||
Column(modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text("Neuen Bewerb anlegen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
SecondaryTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
modifier = Modifier,
|
||||
divider = { HorizontalDivider() }
|
||||
) {
|
||||
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Identifikation") })
|
||||
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }, text = { Text("Details & Finanzen") })
|
||||
Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }, text = { Text("Ort & Zeitplan") })
|
||||
Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }, text = { Text("Richter & Teilung") })
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
when (steps[selectedTab]) {
|
||||
WizardStep.IDENTIFIKATION -> StepIdentifikation(state, onStateChange)
|
||||
WizardStep.DETAILS_FINANZEN -> StepDetailsFinanzen(state, onStateChange)
|
||||
WizardStep.ORT_ZEIT -> StepOrtZeit(state, onStateChange)
|
||||
WizardStep.RICHTER_TEILUNG -> StepRichterTeilung(state, onStateChange)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(enabled = selectedTab > 0, onClick = { selectedTab-- }) { Text("Zurück") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (selectedTab < steps.lastIndex) {
|
||||
TextButton(onClick = { selectedTab++ }) { Text("Weiter") }
|
||||
} else {
|
||||
OutlinedButton(onClick = {
|
||||
val payload = state.toPayloadOrNull()
|
||||
if (payload != null) onSubmit(payload)
|
||||
}) { Text("Bewerb anlegen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepIdentifikation(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.klasse,
|
||||
onValueChange = { onStateChange(state.copy(klasse = it)) },
|
||||
label = { Text("Sparte/Kategorie/Klasse") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.hoeheCm,
|
||||
onValueChange = { onStateChange(state.copy(hoeheCm = it.filter { ch -> ch.isDigit() })) },
|
||||
label = { Text("Höhe (cm)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.bezeichnung,
|
||||
onValueChange = { onStateChange(state.copy(bezeichnung = it)) },
|
||||
label = { Text("Bezeichnung") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepDetailsFinanzen(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.beschreibung,
|
||||
onValueChange = { onStateChange(state.copy(beschreibung = it)) },
|
||||
label = { Text("Beschreibung (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.aufgabe,
|
||||
onValueChange = { onStateChange(state.copy(aufgabe = it)) },
|
||||
label = { Text("Aufgabe (z.B. R1)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.startgeld,
|
||||
onValueChange = { onStateChange(state.copy(startgeld = it.filter { ch -> ch.isDigit() })) },
|
||||
label = { Text("Startgeld (Cent)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = state.geldpreisAusbezahlt, onCheckedChange = { onStateChange(state.copy(geldpreisAusbezahlt = it)) })
|
||||
Text("Geldpreis ausbezahlt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepOrtZeit(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.austragungsplatzId,
|
||||
onValueChange = { onStateChange(state.copy(austragungsplatzId = it)) },
|
||||
label = { Text("Austragungsplatz-ID (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.beginnZeitTyp,
|
||||
onValueChange = { onStateChange(state.copy(beginnZeitTyp = it)) },
|
||||
label = { Text("Beginn (FIX/ANSCHLIESSEND)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.geplantesDatum,
|
||||
onValueChange = { onStateChange(state.copy(geplantesDatum = it)) },
|
||||
label = { Text("Datum (yyyy-MM-dd)") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.beginnZeit,
|
||||
onValueChange = { onStateChange(state.copy(beginnZeit = it)) },
|
||||
label = { Text("Beginn (HH:mm)") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.reitdauerMinuten,
|
||||
onValueChange = { onStateChange(state.copy(reitdauerMinuten = it.filter { ch -> ch.isDigit() })) },
|
||||
label = { Text("Reitdauer (min)") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.umbauMinuten,
|
||||
onValueChange = { onStateChange(state.copy(umbauMinuten = it.filter { ch -> ch.isDigit() })) },
|
||||
label = { Text("Umbau (min)") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.besichtigungMinuten,
|
||||
onValueChange = { onStateChange(state.copy(besichtigungMinuten = it.filter { ch -> ch.isDigit() })) },
|
||||
label = { Text("Besichtigung (min)") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = state.stechenGeplant, onCheckedChange = { onStateChange(state.copy(stechenGeplant = it)) })
|
||||
Text("Stechen geplant")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepRichterTeilung(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
// Warn-Logik (mock): Wenn Richter ausgewählt und Position = "C" ohne weiterer Prüfung -> TB-Hinweis
|
||||
val warnTb = state.richter.isNotEmpty()
|
||||
if (warnTb) {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().background(Color(0xFFFFF8E1)).padding(12.dp)
|
||||
) { Text("Hinweis: Richter-Zuweisung erfordert Freigabe durch TB (Qualifikation prüfen)", color = Color(0xFFFFA000)) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.teilungsTyp,
|
||||
onValueChange = { onStateChange(state.copy(teilungsTyp = it)) },
|
||||
label = { Text("Teilungsregel (z.B. MANUELL)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Minimal-UI für das Hinzufügen eines Richters (freie Eingabe von UUID + Position)
|
||||
var funktionaerId by remember { mutableStateOf("") }
|
||||
var position by remember { mutableStateOf("") }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(funktionaerId, { funktionaerId = it }, label = { Text("Funktionär-ID") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(position, { position = it }, label = { Text("Position (C/M/…)") }, modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = {
|
||||
if (funktionaerId.isNotBlank() && position.isNotBlank()) {
|
||||
val list = state.richter + RichterEinsatzDto(funktionaerId = funktionaerId.trim(), position = position.trim())
|
||||
onStateChange(state.copy(richter = list))
|
||||
funktionaerId = ""; position = ""
|
||||
}
|
||||
}) { Text("Hinzufügen") }
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
state.richter.forEachIndexed { idx, r ->
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("${idx + 1}. ${r.position} – ${r.funktionaerId}")
|
||||
TextButton(onClick = {
|
||||
val list = state.richter.toMutableList().also { it.removeAt(idx) }
|
||||
onStateChange(state.copy(richter = list))
|
||||
}) { Text("Entfernen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mapping UI-State -> API-Payload ---
|
||||
private fun CreateBewerbWizardState.toPayloadOrNull(): CreateBewerbPayload? {
|
||||
if (klasse.isBlank() || bezeichnung.isBlank()) return null
|
||||
|
||||
val hoehe: Int? = hoeheCm.toIntOrNull()
|
||||
val startgeldCent: Long? = startgeld.toLongOrNull()
|
||||
|
||||
val datum: LocalDate? = runCatching { if (geplantesDatum.isBlank()) null else LocalDate.parse(geplantesDatum) }.getOrNull()
|
||||
val zeit: LocalTime? = runCatching { if (beginnZeit.isBlank()) null else LocalTime.parse(beginnZeit) }.getOrNull()
|
||||
val beginnTyp: String? = beginnZeitTyp.ifBlank { null }
|
||||
|
||||
val reitMin = reitdauerMinuten.toIntOrNull()
|
||||
val umbauMin = umbauMinuten.toIntOrNull()
|
||||
val besMin = besichtigungMinuten.toIntOrNull()
|
||||
|
||||
return CreateBewerbPayload(
|
||||
klasse = klasse.trim(),
|
||||
hoeheCm = hoehe,
|
||||
bezeichnung = bezeichnung.trim(),
|
||||
beschreibung = beschreibung.ifBlank { null },
|
||||
aufgabe = aufgabe.ifBlank { null },
|
||||
aufgabenNummer = null,
|
||||
paraGrade = null,
|
||||
austragungsplatzId = austragungsplatzId.ifBlank { null },
|
||||
richterEinsaetze = richter,
|
||||
geplantesDatum = datum,
|
||||
beginnZeitTyp = beginnTyp,
|
||||
beginnZeit = zeit,
|
||||
reitdauerMinuten = reitMin,
|
||||
umbauMinuten = umbauMin,
|
||||
besichtigungMinuten = besMin,
|
||||
stechenGeplant = stechenGeplant,
|
||||
startgeldCent = startgeldCent,
|
||||
geldpreisAusbezahlt = geldpreisAusbezahlt,
|
||||
)
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.features.turnier.domain.Pferd
|
||||
import at.mocode.frontend.features.turnier.domain.Reiter
|
||||
import at.mocode.frontend.features.turnier.domain.Ergebnis
|
||||
import at.mocode.frontend.features.turnier.domain.ErgebnisStatus
|
||||
|
||||
@Composable
|
||||
fun ErgebnisEditDialog(
|
||||
ergebnis: Ergebnis,
|
||||
reiterName: String,
|
||||
pferdName: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Ergebnis) -> Unit
|
||||
) {
|
||||
var wertnote by remember { mutableStateOf(ergebnis.wertnote?.toString() ?: "") }
|
||||
var zeit by remember { mutableStateOf(ergebnis.zeit?.toString() ?: "") }
|
||||
var fehler by remember { mutableStateOf(ergebnis.fehler?.toString() ?: "") }
|
||||
var status by remember { mutableStateOf(ergebnis.status) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Ergebnis erfassen: $reiterName mit $pferdName") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = wertnote,
|
||||
onValueChange = { wertnote = it },
|
||||
label = { Text("Wertnote") }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = zeit,
|
||||
onValueChange = { zeit = it },
|
||||
label = { Text("Zeit") }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = fehler,
|
||||
onValueChange = { fehler = it },
|
||||
label = { Text("Fehler") }
|
||||
)
|
||||
|
||||
Text("Status")
|
||||
Column {
|
||||
ErgebnisStatus.entries.forEach { s ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = status == s, onClick = { status = s })
|
||||
Text(s.name, modifier = Modifier.clickable { status = s })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
onSave(ergebnis.copy(
|
||||
wertnote = wertnote.toDoubleOrNull(),
|
||||
zeit = zeit.toDoubleOrNull(),
|
||||
fehler = fehler.toDoubleOrNull(),
|
||||
status = status
|
||||
))
|
||||
}) {
|
||||
Text("Speichern")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReiterEditDialog(
|
||||
reiter: Reiter,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Reiter) -> Unit
|
||||
) {
|
||||
var vorname by remember { mutableStateOf(reiter.vorname) }
|
||||
var nachname by remember { mutableStateOf(reiter.nachname) }
|
||||
var oepsNummer by remember { mutableStateOf(reiter.oepsNummer ?: "") }
|
||||
var verein by remember { mutableStateOf(reiter.verein ?: "") }
|
||||
var feiId by remember { mutableStateOf(reiter.feiId ?: "") }
|
||||
|
||||
val isVornameValid = vorname.isNotBlank()
|
||||
val isNachnameValid = nachname.isNotBlank()
|
||||
val isOepsValid = oepsNummer.isBlank() || oepsNummer.all { it.isDigit() || it.isLetter() } // Einfache Prüfung
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Reiter bearbeiten") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = vorname,
|
||||
onValueChange = { vorname = it },
|
||||
label = { Text("Vorname*") },
|
||||
isError = !isVornameValid
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = nachname,
|
||||
onValueChange = { nachname = it },
|
||||
label = { Text("Nachname*") },
|
||||
isError = !isNachnameValid
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = oepsNummer,
|
||||
onValueChange = { oepsNummer = it },
|
||||
label = { Text("OEPS-Nr.") },
|
||||
isError = !isOepsValid,
|
||||
supportingText = { if(!isOepsValid) Text("Ungültiges Format") }
|
||||
)
|
||||
OutlinedTextField(value = verein, onValueChange = { verein = it }, label = { Text("Verein") })
|
||||
OutlinedTextField(value = feiId, onValueChange = { feiId = it }, label = { Text("FEI-ID") })
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onSave(reiter.copy(
|
||||
vorname = vorname,
|
||||
nachname = nachname,
|
||||
oepsNummer = oepsNummer,
|
||||
satznummer = oepsNummer,
|
||||
verein = verein,
|
||||
feiId = feiId
|
||||
))
|
||||
},
|
||||
enabled = isVornameValid && isNachnameValid && isOepsValid
|
||||
) {
|
||||
Text("Speichern")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PferdEditDialog(
|
||||
pferd: Pferd,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Pferd) -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf(pferd.name) }
|
||||
var lebensnummer by remember { mutableStateOf(pferd.lebensnummer) }
|
||||
var oepsNummer by remember { mutableStateOf(pferd.oepsNummer ?: "") }
|
||||
var geburtsjahr by remember { mutableStateOf(pferd.geburtsjahr?.toString() ?: "") }
|
||||
|
||||
val isNameValid = name.isNotBlank()
|
||||
val isLebensnrValid = lebensnummer.isNotBlank()
|
||||
val isJahrValid = geburtsjahr.isBlank() || (geburtsjahr.toIntOrNull() != null && geburtsjahr.length == 4)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Pferd bearbeiten") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name*") },
|
||||
isError = !isNameValid
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = lebensnummer,
|
||||
onValueChange = { lebensnummer = it },
|
||||
label = { Text("Lebensnummer*") },
|
||||
isError = !isLebensnrValid
|
||||
)
|
||||
OutlinedTextField(value = oepsNummer, onValueChange = { oepsNummer = it }, label = { Text("OEPS-Nr.") })
|
||||
OutlinedTextField(
|
||||
value = geburtsjahr,
|
||||
onValueChange = { geburtsjahr = it },
|
||||
label = { Text("Geburtsjahr") },
|
||||
isError = !isJahrValid,
|
||||
supportingText = { if(!isJahrValid) Text("4-stellige Jahreszahl") }
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onSave(pferd.copy(
|
||||
name = name,
|
||||
lebensnummer = lebensnummer,
|
||||
oepsNummer = oepsNummer,
|
||||
geburtsjahr = geburtsjahr.toIntOrNull()
|
||||
))
|
||||
},
|
||||
enabled = isNameValid && isLebensnrValid && isJahrValid
|
||||
) {
|
||||
Text("Speichern")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
private val SeriesBlue = Color(0xFF1E3A8A)
|
||||
|
||||
/**
|
||||
* SERIES-Screen gemäß Vision_03 & Phase 10.
|
||||
*/
|
||||
@Composable
|
||||
fun SeriesScreen(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
viewModel: SeriesViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text("Konfiguration & Auswertung (Phase 10)", fontSize = 13.sp, color = Color.Gray)
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.createSerie("Neuer Cup") },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = SeriesBlue)
|
||||
) {
|
||||
Text("Neue Serie anlegen")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (state.series.isEmpty()) {
|
||||
EmptyState(title, onBack)
|
||||
} else {
|
||||
SeriesList(state, onSelect = { viewModel.selectSerie(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesList(state: SeriesState, onSelect: (String) -> Unit) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.weight(0.4f).padding(16.dp)) {
|
||||
items(state.series) { serie ->
|
||||
Card(
|
||||
onClick = { serie.id?.let { onSelect(it) } },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(serie.name, fontWeight = FontWeight.Bold)
|
||||
Text(serie.reglementTyp, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalDivider()
|
||||
Column(Modifier.weight(0.6f).padding(16.dp)) {
|
||||
Text("Zwischenstand", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
state.selectedSerieStand.forEach { entry ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text("Reiter ID: ${entry.reiterId}", fontWeight = FontWeight.Medium)
|
||||
entry.pferdId?.let {
|
||||
Text("Pferd ID: $it", fontSize = 11.sp, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("${entry.punkte} Pkt", fontWeight = FontWeight.Bold, color = SeriesBlue)
|
||||
Text("${entry.anzahlWertungen} Wertungen", fontSize = 10.sp, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 0.5.dp, color = Color.LightGray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(title: String, onBack: () -> Unit) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Keine $title konfiguriert", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Verknüpfe Bewerbe zu einer Serie, um Punktestände automatisch zu berechnen.",
|
||||
fontSize = 13.sp,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedButton(onClick = onBack) {
|
||||
Text("Zurück zur Verwaltung")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import at.mocode.frontend.features.turnier.domain.Serie
|
||||
import at.mocode.frontend.features.turnier.domain.SerieStandEntry
|
||||
import at.mocode.frontend.features.turnier.domain.SeriesRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
||||
data class SeriesState(
|
||||
val series: List<Serie> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val selectedSerieStand: List<SerieStandEntry> = emptyList(),
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class SeriesViewModel(
|
||||
private val repository: SeriesRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SeriesState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadSeries()
|
||||
}
|
||||
|
||||
fun loadSeries() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true)
|
||||
repository.getAll()
|
||||
.onSuccess { series ->
|
||||
_state.value = _state.value.copy(series = series, isLoading = false)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(error = it.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSerie(id: String) {
|
||||
viewModelScope.launch {
|
||||
repository.getStand(id)
|
||||
.onSuccess { stand ->
|
||||
_state.value = _state.value.copy(selectedSerieStand = stand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createSerie(name: String) {
|
||||
viewModelScope.launch {
|
||||
repository.save(Serie(name = name))
|
||||
.onSuccess {
|
||||
loadSeries()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.filled.Print
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
import at.mocode.frontend.features.billing.presentation.BillingScreen
|
||||
import at.mocode.frontend.features.billing.presentation.BillingViewModel
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
private val PrimaryBlue = Color(0xFF1E3A8A)
|
||||
private val AccentBlue = Color(0xFF3B82F6)
|
||||
private val OffenePostenRot = Color(0xFFDC2626)
|
||||
|
||||
/**
|
||||
* ABRECHNUNG-Tab im TurnierDetailScreen.
|
||||
* Gemäß Figma Vision_03 (figma-entwurf_06):
|
||||
* - Sub-Tabs: BUCHUNGEN | OFFENE POSTEN | RECHNUNG
|
||||
* - Rechte Sidebar: AUSWAHL | VERKAUF | BUCHUNGEN | ADRESSEN
|
||||
* - Buchungstabelle: Buchungstext, Soll, Haben, Saldo, Buchen-Checkbox, Rechnung-Checkbox
|
||||
* - Rechte Sidebar: Suche nach Reiter/Pferd, Zahlungsart, Buchen-Button
|
||||
*/
|
||||
@Composable
|
||||
fun AbrechnungTabContent(veranstaltungId: Long) {
|
||||
val billingViewModel: BillingViewModel = koinInject()
|
||||
|
||||
BillingScreen(
|
||||
viewModel = billingViewModel,
|
||||
veranstaltungId = veranstaltungId,
|
||||
onBack = {}
|
||||
)
|
||||
}
|
||||
|
||||
/* Alter Inhalt auskommentiert oder entfernt */
|
||||
@Composable
|
||||
private fun LegacyAbrechnungTabContent() {
|
||||
var subTab by remember { mutableIntStateOf(0) }
|
||||
var sidebarTab by remember { mutableIntStateOf(2) } // BUCHUNGEN default
|
||||
val subTabs = listOf("BUCHUNGEN", "OFFENE POSTEN", "RECHNUNG")
|
||||
val sidebarTabs = listOf("AUSWAHL", "VERKAUF", "BUCHUNGEN", "ADRESSEN")
|
||||
|
||||
// Placeholder-Buchungen
|
||||
val buchungen = remember {
|
||||
listOf(
|
||||
BuchungspositionUiModel("Startgebühr Bewerb 12 - Dressur Kl. A", 25.00, 0.00),
|
||||
BuchungspositionUiModel("Startgebühr Bewerb 15 - Springen Kl. B", 30.00, 0.00),
|
||||
BuchungspositionUiModel("Nenngeld", 15.00, 0.00),
|
||||
BuchungspositionUiModel("Box 3 Tage", 45.00, 0.00),
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Hauptbereich ─────────────────────────────────────────────────────
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
// Sub-Tabs
|
||||
SecondaryTabRow(
|
||||
selectedTabIndex = subTab,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = Color(0xFF1E3A8A),
|
||||
) {
|
||||
subTabs.forEachIndexed { i, title ->
|
||||
Tab(
|
||||
selected = subTab == i,
|
||||
onClick = { subTab = i },
|
||||
text = {
|
||||
Text(
|
||||
title,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (subTab == i) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (subTab) {
|
||||
0 -> BuchungenContent(buchungen)
|
||||
1 -> OffenePostenContent()
|
||||
2 -> RechnungContent()
|
||||
}
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// ── Rechte Sidebar ───────────────────────────────────────────────────
|
||||
Column(modifier = Modifier.width(320.dp).fillMaxHeight()) {
|
||||
SecondaryTabRow(
|
||||
selectedTabIndex = sidebarTab,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = Color(0xFF1E3A8A),
|
||||
) {
|
||||
sidebarTabs.forEachIndexed { i, title ->
|
||||
Tab(
|
||||
selected = sidebarTab == i,
|
||||
onClick = { sidebarTab = i },
|
||||
text = { Text(title, fontSize = 11.sp) },
|
||||
)
|
||||
}
|
||||
}
|
||||
when (sidebarTab) {
|
||||
2 -> BuchungenSidebar()
|
||||
else -> PlaceholderContent(title = sidebarTabs[sidebarTab], subtitle = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuchungenContent(buchungen: List<BuchungspositionUiModel>) {
|
||||
val gesamtSoll = buchungen.sumOf { it.soll }
|
||||
val gesamtHaben = buchungen.sumOf { it.haben }
|
||||
val gesamtSaldo = gesamtSoll - gesamtHaben
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Aktualisieren", fontSize = 12.sp)
|
||||
}
|
||||
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
|
||||
Text("Übersicht", fontSize = 12.sp)
|
||||
}
|
||||
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
|
||||
Text("Tabelle Leeren", fontSize = 12.sp, color = Color(0xFFEA580C))
|
||||
}
|
||||
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
|
||||
Text("Pferd aus Liste entfernen", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// Tabellen-Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(Color(0xFFF3F4F6)).padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text("Buchungstext", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f))
|
||||
Text("Soll", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Haben", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Saldo", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Buchen", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
|
||||
Text("Rechnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(buchungen) { b ->
|
||||
val saldo = b.soll - b.haben
|
||||
var buchen by remember { mutableStateOf(false) }
|
||||
var rechnung by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(b.buchungstext, fontSize = 13.sp, modifier = Modifier.weight(3f))
|
||||
Text("%.2f €".format(b.soll), fontSize = 13.sp, modifier = Modifier.weight(1f))
|
||||
Text("%.2f €".format(b.haben), fontSize = 13.sp, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
"%.2f €".format(saldo),
|
||||
fontSize = 13.sp,
|
||||
color = if (saldo > 0) OffenePostenRot else Color.Unspecified,
|
||||
fontWeight = if (saldo > 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Checkbox(checked = buchen, onCheckedChange = { buchen = it }, modifier = Modifier.width(60.dp))
|
||||
Checkbox(checked = rechnung, onCheckedChange = { rechnung = it }, modifier = Modifier.width(70.dp))
|
||||
}
|
||||
HorizontalDivider(color = Color(0xFFE5E7EB))
|
||||
}
|
||||
}
|
||||
|
||||
// Gesamt-Zeile
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("GESAMT", fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(3f))
|
||||
Text("%.2f €".format(gesamtSoll), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
Text("%.2f €".format(gesamtHaben), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
"%.2f €".format(gesamtSaldo),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (gesamtSaldo > 0) OffenePostenRot else Color.Unspecified,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuchungenSidebar() {
|
||||
var suchtext by remember { mutableStateOf("") }
|
||||
var zahlungsart by remember { mutableStateOf("BAR") }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Nach Reiter oder Pferd", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
OutlinedTextField(
|
||||
value = suchtext,
|
||||
onValueChange = { suchtext = it },
|
||||
placeholder = { Text("Bitte auswählen...", fontSize = 12.sp) },
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text("Buchen:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("0.00 €", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
|
||||
) { Text("Buchen") }
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text("Direkt Drucken:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.Print, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Saldo", fontSize = 12.sp)
|
||||
}
|
||||
OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) {
|
||||
Text("Rechnung", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text("Zahlungsart:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
listOf("BAR", "Scheck (+30 €)", "Bankomat", "Kreditkarte").forEach { art ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = zahlungsart == art, onClick = { zahlungsart = art })
|
||||
Text(art, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
|
||||
) { Text("Gebühr buchen") }
|
||||
|
||||
// Hinweis
|
||||
Surface(
|
||||
color = Color(0xFFEFF6FF),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)),
|
||||
) {
|
||||
Text(
|
||||
"💡 Hinweis: Bei Barzahlung werden die Buchungen sofort verarbeitet. Scheck-Zahlungen erfordern eine zusätzliche Gebühr von 30 €.",
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF1E40AF),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OffenePostenContent() {
|
||||
PlaceholderContent(title = "Offene Posten", subtitle = "Alle offenen Forderungen …")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RechnungContent() {
|
||||
PlaceholderContent(title = "Rechnung", subtitle = "Rechnungserstellung …")
|
||||
}
|
||||
|
||||
// --- UI-Modelle ---
|
||||
data class BuchungspositionUiModel(val buchungstext: String, val soll: Double, val haben: Double)
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val PrimaryBlue = Color(0xFF1E3A8A)
|
||||
private val AccentBlue = Color(0xFF3B82F6)
|
||||
private val DeleteRed = Color(0xFFDC2626)
|
||||
|
||||
/**
|
||||
* ARTIKEL-Tab im TurnierDetailScreen.
|
||||
* Gemäß Figma Vision_03 (figma-entwurf_07 / figma-entwurf_08):
|
||||
* - Nennungen & Gebühren: Nenngebühr, Startgebühr, Sporteuro, Nachnennungsgebühr, Nennungstausch
|
||||
* - Stallungen & Boxen: Box/Tag, Einstreu, Paddock
|
||||
* - Zusatzgebühren: dynamische Liste (Bezeichnung, Betrag, Pflicht)
|
||||
*/
|
||||
@Composable
|
||||
fun ArtikelTabContent() {
|
||||
var nenngebuehr by remember { mutableStateOf("0.00") }
|
||||
var startgebuehr by remember { mutableStateOf("15.00") }
|
||||
var sporteuro by remember { mutableStateOf("0.00") }
|
||||
var nachnennungsgebuehr by remember { mutableStateOf("0.00") }
|
||||
var nennungstauschGebuehr by remember { mutableStateOf("0.00") }
|
||||
var boxProTag by remember { mutableStateOf("0.00") }
|
||||
var einstreuErstEinstreu by remember { mutableStateOf("0.00") }
|
||||
var einstreuNachlegen by remember { mutableStateOf("0.00") }
|
||||
var paddockProTag by remember { mutableStateOf("0.00") }
|
||||
|
||||
var zusatzgebuehren by remember {
|
||||
mutableStateOf(
|
||||
listOf(
|
||||
ZusatzgebuehrUiModel("Stromanschluss pro Tag", "5.00", false),
|
||||
ZusatzgebuehrUiModel("Camping pro Nacht", "10.00", false),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// ── Nennungen & Gebühren ─────────────────────────────────────────────
|
||||
ArtikelSectionCard(title = "Nennungen & Gebühren") {
|
||||
ArtikelSubSection("Nennungs- und Startgebühren") {
|
||||
ArtikelFormRow("Nenngebühr pro Pferd/Reiter:", "(Grundgebühr unabhängig von Anzahl Bewerben)") {
|
||||
EuroTextField(nenngebuehr) { nenngebuehr = it }
|
||||
}
|
||||
ArtikelFormRow("Startgebühr pro Bewerb:", "(Pro einzelner Prüfung)") {
|
||||
EuroTextField(startgebuehr) { startgebuehr = it }
|
||||
}
|
||||
ArtikelFormRow("Sporteuro (Beitrag OEPS):", null) {
|
||||
EuroTextField(sporteuro) { sporteuro = it }
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
ArtikelFormRow("Nachnennungsgebühr:", "(Nach Nennschluss)") {
|
||||
EuroTextField(nachnennungsgebuehr) { nachnennungsgebuehr = it }
|
||||
}
|
||||
ArtikelFormRow("Nennungstausch-Gebühr:", "(Pferd- oder Reiter-Wechsel)") {
|
||||
EuroTextField(nennungstauschGebuehr) { nennungstauschGebuehr = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stallungen & Boxen ───────────────────────────────────────────────
|
||||
ArtikelSectionCard(title = "Stallungen & Boxen") {
|
||||
ArtikelFormRow("Box pro Tag:", null) {
|
||||
EuroTextField(boxProTag) { boxProTag = it }
|
||||
}
|
||||
ArtikelFormRow("Einstreu (Erst-Einstreu):", null) {
|
||||
EuroTextField(einstreuErstEinstreu) { einstreuErstEinstreu = it }
|
||||
}
|
||||
ArtikelFormRow("Einstreu (Nachlegen):", null) {
|
||||
EuroTextField(einstreuNachlegen) { einstreuNachlegen = it }
|
||||
}
|
||||
ArtikelFormRow("Paddock pro Tag:", null) {
|
||||
EuroTextField(paddockProTag) { paddockProTag = it }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Zusatzgebühren ───────────────────────────────────────────────────
|
||||
ArtikelSectionCard(
|
||||
title = "Zusatzgebühren",
|
||||
action = {
|
||||
TextButton(onClick = {
|
||||
zusatzgebuehren = zusatzgebuehren + ZusatzgebuehrUiModel("", "0.00", false)
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Hinzufügen", color = AccentBlue, fontSize = 13.sp)
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Tabellen-Header
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
|
||||
Text("Bezeichnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f))
|
||||
Text("Betrag", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f))
|
||||
Text("Pflicht", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Spacer(Modifier.width(44.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
zusatzgebuehren.forEachIndexed { idx, z ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = z.bezeichnung,
|
||||
onValueChange = { v ->
|
||||
zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(bezeichnung = v) }
|
||||
},
|
||||
modifier = Modifier.weight(3f).height(44.dp).padding(end = 8.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = z.betrag,
|
||||
onValueChange = { v ->
|
||||
zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(betrag = v) }
|
||||
},
|
||||
suffix = { Text("€") },
|
||||
modifier = Modifier.weight(1.5f).height(44.dp).padding(end = 8.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = z.pflicht,
|
||||
onCheckedChange = { v ->
|
||||
zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(pflicht = v) }
|
||||
},
|
||||
)
|
||||
Text("Pflicht", fontSize = 12.sp)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { zusatzgebuehren = zusatzgebuehren.toMutableList().also { it.removeAt(idx) } },
|
||||
modifier = Modifier.size(44.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Löschen",
|
||||
tint = DeleteRed,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hinweis ──────────────────────────────────────────────────────────
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color(0xFFFFFBEB),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFFDE68A)),
|
||||
) {
|
||||
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("ℹ️", fontSize = 14.sp)
|
||||
Column {
|
||||
Text("Hinweis zur Preisliste", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Die Gebührenstruktur wird in der offiziellen Ausschreibung veröffentlicht und ist für alle Teilnehmer verbindlich. " +
|
||||
"Bei nationalen Turnieren der Kategorie C-Neu sind oft reduzierte Gebühren oder Gebührenbefreiungen üblich (z.B. kein Nenngeld, kein Sporteuro).",
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFF92400E),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aktions-Buttons ──────────────────────────────────────────────────
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
OutlinedButton(onClick = {}) { Text("Zurücksetzen") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = {}, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)) { Text("Speichern") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtikelSectionCard(
|
||||
title: String,
|
||||
action: @Composable (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
|
||||
action?.invoke()
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtikelSubSection(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF9FAFB)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtikelFormRow(label: String, hint: String?, content: @Composable RowScope.() -> Unit) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, fontSize = 13.sp, modifier = Modifier.width(220.dp), color = Color(0xFF374151))
|
||||
content()
|
||||
if (hint != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
hint,
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF9CA3AF),
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EuroTextField(value: String, onValueChange: (String) -> Unit) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
suffix = { Text("€") },
|
||||
modifier = Modifier.width(120.dp).height(44.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
// --- UI-Modelle ---
|
||||
data class ZusatzgebuehrUiModel(val bezeichnung: String, val betrag: String, val pflicht: Boolean)
|
||||
+1030
File diff suppressed because it is too large
Load Diff
+137
@@ -0,0 +1,137 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
/**
|
||||
* Detailansicht eines Turniers gemäß Vision_03.
|
||||
*
|
||||
* Layout: Horizontale Tab-Bar mit 8 Tabs (kein eigener Toolbar-Zurück-Button –
|
||||
* Navigation erfolgt über den Breadcrumb in der TopBar).
|
||||
*
|
||||
* Tabs:
|
||||
* 1. STAMMDATEN – Turnier-Konfiguration, ZNS-Import, Sparten, Datum
|
||||
* 2. ORGANISATION – Funktionäre, Richterkollegium, Austragungsplätze
|
||||
* 3. BEWERBE – 3-spaltiges Layout (Aktionen | Tabelle | Detail-Panel)
|
||||
* 4. ARTIKEL – Gebühren, Stallungen & Boxen, Zusatzgebühren
|
||||
* 5. ABRECHNUNG – Buchungen, Offene Posten, Rechnung
|
||||
* 6. NENNUNGEN – Pferd+Reiter-Suche, Verkauf/Buchungen, Bewerbsübersicht
|
||||
* 7. STARTLISTEN – Bewerbs-Tabs, Sortierung, Zeit/Dauer
|
||||
* 8. ERGEBNISLISTEN – Bewerbs-Tabs, Platzierung & Geldpreise
|
||||
*
|
||||
*/
|
||||
@Composable
|
||||
fun TurnierDetailScreen(
|
||||
veranstaltungId: Long,
|
||||
turnierId: Long,
|
||||
onBack: () -> Unit,
|
||||
eventVon: String? = null,
|
||||
eventBis: String? = null,
|
||||
eventOrt: String? = null,
|
||||
veranstalterName: String? = null,
|
||||
veranstalterOrt: String? = null,
|
||||
veranstalterBundesland: String? = null,
|
||||
veranstalterLogoUrl: String? = null,
|
||||
) {
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Temporäre Lösung bis zur echten Repository-Anbindung:
|
||||
// Da TurnierDetailScreen in einem anderen Modul liegt, übergeben wir
|
||||
// die Veranstaltungsinformationen eigentlich via ViewModel.
|
||||
// Hier nutzen wir vorerst koin oder Parameter.
|
||||
|
||||
val tabs = listOf(
|
||||
"STAMMDATEN",
|
||||
"ORGANISATION",
|
||||
"BEWERBE",
|
||||
"ARTIKEL",
|
||||
"ABRECHNUNG",
|
||||
"NENNUNGEN",
|
||||
"ONLINE-EINGANG",
|
||||
"ZEITPLAN",
|
||||
"STARTLISTEN",
|
||||
"ERGEBNISLISTEN",
|
||||
)
|
||||
|
||||
val bewerbViewModel: BewerbViewModel = koinInject { parametersOf(turnierId) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Horizontale Tab-Bar (direkt unter der TopBar)
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = Color(0xFF1E3A8A),
|
||||
edgePadding = 0.dp,
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Tab-Inhalte
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (selectedTab) {
|
||||
0 -> StammdatenTabContent(
|
||||
turnierId = turnierId,
|
||||
eventVon = eventVon,
|
||||
eventBis = eventBis,
|
||||
eventOrt = eventOrt,
|
||||
veranstalterName = veranstalterName,
|
||||
veranstalterOrt = veranstalterOrt,
|
||||
veranstalterBundesland = veranstalterBundesland,
|
||||
veranstalterLogoUrl = veranstalterLogoUrl,
|
||||
)
|
||||
1 -> {
|
||||
val nennungViewModel = koinInject<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
|
||||
OrganisationTabContent(viewModel = nennungViewModel)
|
||||
}
|
||||
2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId)
|
||||
3 -> ArtikelTabContent()
|
||||
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
|
||||
5 -> {
|
||||
val nennungViewModel = koinInject<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
|
||||
NennungenTabContent(
|
||||
viewModel = nennungViewModel,
|
||||
onAbrechnungClick = { selectedTab = 4 }
|
||||
)
|
||||
}
|
||||
6 -> {
|
||||
val nennungViewModel = koinInject<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
|
||||
OnlineNennungEingangTabContent(turnierNr = turnierId.toString(), viewModel = nennungViewModel)
|
||||
}
|
||||
7 -> ZeitplanTabContent(turnierId = turnierId, viewModel = bewerbViewModel)
|
||||
8 -> StartlistenTabContent()
|
||||
9 -> ErgebnislistenTabContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab-Inhalte werden in dedizierten Dateien implementiert:
|
||||
// TurnierBewerbeTab.kt → BewerbeTabContent()
|
||||
// TurnierNennungenTab.kt → NennungenTabContent()
|
||||
// TurnierStartlistenTab.kt → StartlistenTabContent()
|
||||
// TurnierZeitplanTab.kt → ZeitplanTabContent()
|
||||
// TurnierErgebnislistenTab.kt → ErgebnislistenTabContent()
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.features.turnier.domain.Ergebnis
|
||||
import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
private val ElBlue = Color(0xFF1E3A8A)
|
||||
private val ElHeaderBg = Color(0xFFF1F5F9)
|
||||
|
||||
/**
|
||||
* ERGEBNISLISTEN-Tab gemäß Vision_03.
|
||||
*
|
||||
* Layout: 2-spaltig
|
||||
* - Links (flex): Bewerbs-Tabs + Ergebnis-Tabelle (Platz | Startnr | Pferd | Reiter | Fehler | Zeit | Punkte)
|
||||
* - Rechts (280dp): Platzierung & Geldpreis-Panel
|
||||
*/
|
||||
@Composable
|
||||
fun ErgebnislistenTabContent(
|
||||
viewModel: BewerbViewModel = koinInject()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Linke Spalte: Bewerbs-Tabs + Tabelle ─────────────────────────────
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
ErgebnislistenBewerbsTabs(
|
||||
bewerbe = state.list,
|
||||
selectedId = state.selectedId,
|
||||
onSelect = { viewModel.send(BewerbIntent.Select(it)) },
|
||||
ergebnisse = state.ergebnisse,
|
||||
startliste = state.currentStartliste,
|
||||
onCalculate = { viewModel.send(BewerbIntent.CalculatePlatzierung) },
|
||||
onPrint = { viewModel.send(BewerbIntent.ExportErgebnislistePdf) }
|
||||
)
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// ── Rechte Spalte: Platzierung & Geldpreis ───────────────────────────
|
||||
PlatzierungGeldpreisPanel(
|
||||
modifier = Modifier.width(280.dp).fillMaxHeight(),
|
||||
ergebnisse = state.ergebnisse
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErgebnislistenBewerbsTabs(
|
||||
bewerbe: List<BewerbListItem>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long?) -> Unit,
|
||||
ergebnisse: List<Ergebnis>,
|
||||
startliste: List<StartlistenZeile>,
|
||||
onCalculate: () -> Unit,
|
||||
onPrint: () -> Unit
|
||||
) {
|
||||
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
|
||||
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedIndex,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = ElBlue,
|
||||
edgePadding = 0.dp,
|
||||
) {
|
||||
bewerbe.forEachIndexed { index, bewerb ->
|
||||
Tab(
|
||||
selected = selectedId == bewerb.id,
|
||||
onClick = { onSelect(bewerb.id) },
|
||||
text = { Text(bewerb.tag, fontSize = 12.sp) },
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
val selectedBewerb = bewerbe.getOrNull(selectedIndex)
|
||||
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = selectedBewerb?.let { "${it.tag} – ${it.name}" } ?: "Kein Bewerb ausgewählt",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
OutlinedButton(
|
||||
onClick = onCalculate,
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp)
|
||||
) {
|
||||
Text("Platzierung berechnen", fontSize = 12.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp)
|
||||
) {
|
||||
Text("Exportieren", fontSize = 12.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onPrint,
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp)
|
||||
) {
|
||||
Text("Drucken", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// Tabellen-Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp))
|
||||
Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(65.dp))
|
||||
Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Fehler", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
|
||||
Text("Zeit", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
|
||||
Text("Punkte", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
if (ergebnisse.isEmpty()) {
|
||||
// Leere Liste
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Keine Ergebnisse vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Ergebnisse werden nach dem Turnier eingetragen.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {}) {
|
||||
Text("Ergebnisse importieren", fontSize = 13.sp)
|
||||
}
|
||||
Button(
|
||||
onClick = {},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
|
||||
) {
|
||||
Text("Ergebnisse eingeben", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
androidx.compose.foundation.lazy.LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(ergebnisse.size) { index ->
|
||||
val erg = ergebnisse[index]
|
||||
val zeile = startliste.find { it.nennungId == erg.nennungId }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(erg.platzierung?.let { "$it." } ?: "-", fontSize = 12.sp, modifier = Modifier.width(50.dp), fontWeight = FontWeight.Bold, color = ElBlue)
|
||||
Text(zeile?.nr?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(65.dp))
|
||||
Text(zeile?.pferd ?: "Unbekannt", fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
Text(zeile?.reiter ?: "Unbekannt", fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
Text(erg.fehler?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(60.dp))
|
||||
Text(erg.zeit?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(70.dp))
|
||||
Text(erg.wertnote?.toString() ?: "-", fontSize = 12.sp, modifier = Modifier.width(70.dp))
|
||||
}
|
||||
HorizontalDivider(color = Color(0xFFE5E7EB))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlatzierungGeldpreisPanel(
|
||||
modifier: Modifier = Modifier,
|
||||
ergebnisse: List<Ergebnis> = emptyList()
|
||||
) {
|
||||
val platzierteCount = ergebnisse.count { it.platzierung != null }
|
||||
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Platzierung & Geldpreis", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
HorizontalDivider()
|
||||
|
||||
// Anzahl Platzierte
|
||||
Text("Platzierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Anzahl Platzierte:", fontSize = 12.sp, modifier = Modifier.width(140.dp))
|
||||
OutlinedTextField(
|
||||
value = platzierteCount.toString(),
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(60.dp),
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Stechen ab Platz:", fontSize = 12.sp, modifier = Modifier.width(140.dp))
|
||||
OutlinedTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(60.dp),
|
||||
singleLine = true,
|
||||
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Geldpreise
|
||||
Text("Geldpreise", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp))
|
||||
Text("Betrag (€)", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
}
|
||||
HorizontalDivider()
|
||||
listOf(1 to "–", 2 to "–", 3 to "–").forEach { (platz, betrag) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"$platz.",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ElBlue,
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = betrag,
|
||||
onValueChange = {},
|
||||
modifier = Modifier.weight(1f).height(36.dp),
|
||||
singleLine = true,
|
||||
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = Color(0xFFE5E7EB))
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Aktions-Buttons
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Button(
|
||||
onClick = {},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Platzierung berechnen", fontSize = 12.sp)
|
||||
}
|
||||
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Ergebnisliste drucken", fontSize = 12.sp)
|
||||
}
|
||||
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Geldpreise auszahlen", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import at.mocode.frontend.features.turnier.data.remote.dto.NennungEinreichenRequest
|
||||
import at.mocode.frontend.features.turnier.domain.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// --- Mock-Modelle für Online-Nennungen innerhalb dieses Moduls ---
|
||||
data class OnlineNennung(
|
||||
val id: String,
|
||||
val vorname: String,
|
||||
val nachname: String,
|
||||
val lizenz: String,
|
||||
val pferdName: String,
|
||||
val pferdAlter: String,
|
||||
val email: String,
|
||||
val bewerbe: String
|
||||
)
|
||||
|
||||
data class TurnierOnlineUiState(
|
||||
val onlineNennungen: List<OnlineNennung> = emptyList(),
|
||||
val isOnlineLoading: Boolean = false
|
||||
)
|
||||
|
||||
data class NennungenState(
|
||||
val isLoading: Boolean = false,
|
||||
val nennungen: List<Nennung> = emptyList(),
|
||||
val searchResultsReiter: List<Reiter> = emptyList(),
|
||||
val searchResultsPferde: List<Pferd> = emptyList(),
|
||||
val searchResultsFunktionaere: List<Funktionaer> = emptyList(),
|
||||
val selectedReiter: Reiter? = null,
|
||||
val selectedPferd: Pferd? = null,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
class TurnierNennungViewModel(
|
||||
private val nennungRepo: NennungRepository,
|
||||
private val masterdataRepo: MasterdataRepository,
|
||||
private val turnierId: Long
|
||||
) {
|
||||
// UI-State für den Online-Eingang Tab
|
||||
val uiState = MutableStateFlow(TurnierOnlineUiState())
|
||||
|
||||
fun loadOnlineNennungen() {
|
||||
uiState.value = uiState.value.copy(isOnlineLoading = true)
|
||||
scope.launch {
|
||||
// Mock-Laden
|
||||
kotlinx.coroutines.delay(500)
|
||||
uiState.value = uiState.value.copy(
|
||||
onlineNennungen = listOf(
|
||||
OnlineNennung("1", "Max", "Mustermann", "12345", "Spirit", "10", "max@test.at", "1, 2, 5")
|
||||
),
|
||||
isOnlineLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun uebernehmeOnlineNennung(nennung: OnlineNennung) {
|
||||
// Logik zur Übernahme
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _state = MutableStateFlow(NennungenState())
|
||||
val state: StateFlow<NennungenState> = _state
|
||||
|
||||
init {
|
||||
loadNennungen()
|
||||
}
|
||||
|
||||
fun loadNennungen() {
|
||||
_state.value = _state.value.copy(isLoading = true)
|
||||
scope.launch {
|
||||
nennungRepo.list(turnierId).onSuccess { list ->
|
||||
_state.value = _state.value.copy(nennungen = list, isLoading = false)
|
||||
}.onFailure {
|
||||
_state.value = _state.value.copy(errorMessage = "Fehler beim Laden: ${it.message}", isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchReiter(query: String) {
|
||||
if (query.length < 2) {
|
||||
_state.value = _state.value.copy(searchResultsReiter = emptyList())
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
masterdataRepo.searchReiter(query).onSuccess { list ->
|
||||
_state.value = _state.value.copy(searchResultsReiter = list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectReiter(reiter: Reiter?) {
|
||||
_state.value = _state.value.copy(selectedReiter = reiter)
|
||||
}
|
||||
|
||||
fun saveReiter(reiter: Reiter) {
|
||||
scope.launch {
|
||||
masterdataRepo.saveReiter(reiter).onSuccess {
|
||||
_state.value = _state.value.copy(selectedReiter = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchPferde(query: String) {
|
||||
if (query.length < 2) {
|
||||
_state.value = _state.value.copy(searchResultsPferde = emptyList())
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
masterdataRepo.searchPferde(query).onSuccess { list ->
|
||||
_state.value = _state.value.copy(searchResultsPferde = list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectPferd(pferd: Pferd?) {
|
||||
_state.value = _state.value.copy(selectedPferd = pferd)
|
||||
}
|
||||
|
||||
fun savePferd(pferd: Pferd) {
|
||||
scope.launch {
|
||||
masterdataRepo.savePferd(pferd).onSuccess {
|
||||
_state.value = _state.value.copy(selectedPferd = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchFunktionaere(query: String) {
|
||||
if (query.length < 2) {
|
||||
_state.value = _state.value.copy(searchResultsFunktionaere = emptyList())
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
masterdataRepo.searchFunktionaere(query).onSuccess { list ->
|
||||
_state.value = _state.value.copy(searchResultsFunktionaere = list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun einreichen(bewerbId: String, abteilungId: String, reiterId: String, pferdId: String) {
|
||||
_state.value = _state.value.copy(isLoading = true)
|
||||
scope.launch {
|
||||
val request = NennungEinreichenRequest(
|
||||
abteilungId = abteilungId,
|
||||
bewerbId = bewerbId,
|
||||
turnierId = turnierId.toString(),
|
||||
reiterId = reiterId,
|
||||
pferdId = pferdId
|
||||
)
|
||||
nennungRepo.einreichen(request).onSuccess {
|
||||
loadNennungen()
|
||||
}.onFailure {
|
||||
_state.value = _state.value.copy(errorMessage = "Fehler beim Einreichen: ${it.message}", isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val NennBlue = Color(0xFF1E3A8A)
|
||||
private val NennHeaderBg = Color(0xFFF1F5F9)
|
||||
private val NennSelectedBg = Color(0xFFEFF6FF)
|
||||
|
||||
/**
|
||||
* NENNUNGEN-Tab gemäß Vision_03.
|
||||
*
|
||||
* Layout: 2-spaltig
|
||||
* - Links (flex): Pferd+Reiter-Suche + Nennungs-Tabelle
|
||||
* - Rechts (360dp): Verkauf/Buchungen + Bewerbsübersicht
|
||||
*/
|
||||
@Composable
|
||||
fun NennungenTabContent(
|
||||
viewModel: TurnierNennungViewModel,
|
||||
onAbrechnungClick: () -> Unit = {}
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
// --- Editoren ---
|
||||
state.selectedReiter?.let { reiter ->
|
||||
ReiterEditDialog(
|
||||
reiter = reiter,
|
||||
onDismiss = { viewModel.selectReiter(null) },
|
||||
onSave = { viewModel.saveReiter(it) }
|
||||
)
|
||||
}
|
||||
state.selectedPferd?.let { pferd ->
|
||||
PferdEditDialog(
|
||||
pferd = pferd,
|
||||
onDismiss = { viewModel.selectPferd(null) },
|
||||
onSave = { viewModel.savePferd(it) }
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Linke Spalte: Suche + Tabelle ─────────────────────────────────────
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
NennungenSuchePanel(viewModel)
|
||||
HorizontalDivider()
|
||||
NennungenTabelle(viewModel, state)
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// ── Rechte Spalte: Verkauf + Bewerbsübersicht ─────────────────────────
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(360.dp)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
VerkaufBuchungenPanel(onAbrechnungClick)
|
||||
HorizontalDivider()
|
||||
BewerbsuebersichtPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NennungenSuchePanel(viewModel: TurnierNennungViewModel) {
|
||||
var pferdQuery by remember { mutableStateOf("") }
|
||||
var reiterQuery by remember { mutableStateOf("") }
|
||||
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Pferd & Reiter suchen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = pferdQuery,
|
||||
onValueChange = {
|
||||
pferdQuery = it
|
||||
viewModel.searchPferde(it)
|
||||
},
|
||||
placeholder = { Text("Pferd suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = reiterQuery,
|
||||
onValueChange = {
|
||||
reiterQuery = it
|
||||
viewModel.searchReiter(it)
|
||||
},
|
||||
placeholder = { Text("Reiter suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(
|
||||
onClick = { /* In einem echten Dialog würde hier die Auswahl kombiniert */ },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NennBlue),
|
||||
modifier = Modifier.height(44.dp),
|
||||
) {
|
||||
Text("Nennen", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NennungenTabelle(viewModel: TurnierNennungViewModel, state: NennungenState) {
|
||||
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(NennHeaderBg)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text("ID", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
|
||||
Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Status", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
if (state.isLoading) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
if (state.nennungen.isEmpty() && !state.isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Keine Nennungen vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Suchen Sie nach Pferd und Reiter, um eine EntryManagement hinzuzufügen.",
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFF9CA3AF)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(state.nennungen) { index, nennung ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (index == selectedIndex) NennSelectedBg else Color.Transparent)
|
||||
.clickable { selectedIndex = index }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
nennung.id.takeLast(6),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.width(60.dp),
|
||||
color = NennBlue,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(nennung.pferdId.takeLast(8), fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
Text(nennung.reiterId.takeLast(8), fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
NennungStatusBadge(nennung.status)
|
||||
}
|
||||
HorizontalDivider(color = Color(0xFFE5E7EB))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NennungStatusBadge(status: String) {
|
||||
val (bg, fg) = when (status) {
|
||||
"Gemeldet" -> Color(0xFFDCFCE7) to Color(0xFF16A34A)
|
||||
"Bezahlt" -> Color(0xFFDBEAFE) to NennBlue
|
||||
"Abgemeldet" -> Color(0xFFFEE2E2) to Color(0xFFDC2626)
|
||||
else -> Color(0xFFF3F4F6) to Color(0xFF6B7280)
|
||||
}
|
||||
Surface(shape = MaterialTheme.shapes.small, color = bg) {
|
||||
Text(
|
||||
text = status,
|
||||
fontSize = 10.sp,
|
||||
color = fg,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerkaufBuchungenPanel(onAbrechnungClick: () -> Unit = {}) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Verkauf / Buchungen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
TextButton(onClick = onAbrechnungClick) {
|
||||
Text("Zur Abrechnung", fontSize = 11.sp, color = NennBlue)
|
||||
}
|
||||
}
|
||||
|
||||
// Artikel-Buchungen
|
||||
Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Artikel-Buchungen", fontSize = 12.sp, fontWeight = FontWeight.Medium, color = Color(0xFF374151))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(NennHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text("Artikel", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Menge", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp))
|
||||
Text("Preis", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
"Keine Buchungen",
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFF9CA3AF),
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp)
|
||||
) {
|
||||
Text("+ Artikel buchen", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BewerbsuebersichtPanel() {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Bewerbsübersicht", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(NennHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text("Bewerb", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Nennungen", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
listOf(
|
||||
"Bewerb 1 – Dressur Kl. A" to 0,
|
||||
"Bewerb 2 – Dressur Kl. L" to 0,
|
||||
"Bewerb 3 – Springen Kl. A" to 0,
|
||||
).forEach { (name, count) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(name, fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
Text("$count", fontSize = 12.sp, modifier = Modifier.width(80.dp), color = Color(0xFF6B7280))
|
||||
}
|
||||
HorizontalDivider(color = Color(0xFFE5E7EB))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI-Modell ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private data class NennungUiModel(
|
||||
val startnr: Int,
|
||||
val pferd: String,
|
||||
val reiter: String,
|
||||
val bewerb: String,
|
||||
val status: String,
|
||||
)
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
|
||||
private val PrimaryBlue = Color(0xFF1E3A8A)
|
||||
|
||||
/**
|
||||
* Formular zum Anlegen eines neuen Turniers (Vision_03: /veranstaltung/{id}/turnier/neu).
|
||||
* Tabs: Übersicht | Stammdaten (A-Satz) | Organisation | Bewerbe ⭐ | Preisliste
|
||||
*/
|
||||
@Composable
|
||||
fun TurnierNeuScreen(
|
||||
veranstaltungId: Long,
|
||||
onBack: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
var selectedTab by remember { mutableIntStateOf(3) } // Bewerbe ist Standard-Tab (⭐)
|
||||
val tabs = listOf("Stammdaten", "Organisation", "Bewerbe ⭐", "Preisliste")
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
|
||||
// Header
|
||||
Surface(shadowElevation = 4.dp, color = Color.White) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Neues Turnier (Veranstaltung #$veranstaltungId)",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onSave,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
|
||||
) {
|
||||
Text("Turnier speichern")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Tab Navigation as Stepper-like UI
|
||||
PrimaryTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
containerColor = Color.White,
|
||||
contentColor = PrimaryBlue,
|
||||
divider = {}
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
StepCircle(
|
||||
number = (index + 1).toString(),
|
||||
isActive = selectedTab == index,
|
||||
isCompleted = index < selectedTab
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content Area
|
||||
Box(modifier = Modifier.weight(1f).padding(32.dp)) {
|
||||
when (selectedTab) {
|
||||
0 -> PlaceholderContent("Stammdaten", "OEPS-Turniernummer, Kategorie, Sparte …")
|
||||
1 -> PlaceholderContent("Organisation", "Richter, Parcourschef, Tierarzt …")
|
||||
2 -> {
|
||||
// Hier binden wir später das reale Bewerbe-Tab ein
|
||||
PlaceholderContent("Bewerbe", "Bewerbe anlegen und Abteilungen konfigurieren …")
|
||||
}
|
||||
3 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
|
||||
}
|
||||
}
|
||||
|
||||
// Footer Navigation
|
||||
Surface(shadowElevation = 8.dp, color = Color.White) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
|
||||
Row {
|
||||
if (selectedTab > 0) {
|
||||
OutlinedButton(onClick = { selectedTab-- }) {
|
||||
Text("Zurück")
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
if (selectedTab < tabs.size - 1) {
|
||||
Button(
|
||||
onClick = { selectedTab++ },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
|
||||
) {
|
||||
Text("Weiter")
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onSave,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
|
||||
) {
|
||||
Text("Finalisieren & Speichern")
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepCircle(number: String, isActive: Boolean, isCompleted: Boolean) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.background(
|
||||
color = when {
|
||||
isCompleted || isActive -> PrimaryBlue
|
||||
else -> Color.LightGray
|
||||
},
|
||||
shape = androidx.compose.foundation.shape.CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isCompleted) {
|
||||
Icon(Icons.Default.Check, null, tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
} else {
|
||||
Text(
|
||||
text = number,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun OnlineNennungEingangTabContent(turnierNr: String, viewModel: TurnierNennungViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text("Eingegangene Online-Nennungen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Turnier: $turnierNr", style = MaterialTheme.typography.bodyMedium, color = Color.Gray)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.loadOnlineNennungen() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
|
||||
enabled = !uiState.isOnlineLoading
|
||||
) {
|
||||
if (uiState.isOnlineLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = Color.White, strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Aktualisieren")
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.onlineNennungen.isEmpty() && !uiState.isOnlineLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine neuen Nennungen vorhanden.", color = Color.Gray)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items(uiState.onlineNennungen) { nennung ->
|
||||
NennungEingangCard(nennung, onUebernehmen = { viewModel.uebernehmeOnlineNennung(nennung) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NennungEingangCard(nennung: OnlineNennung, onUebernehmen: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("${nennung.vorname} ${nennung.nachname}", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Badge(containerColor = Color(0xFFE3F2FD)) { Text(nennung.lizenz, color = Color(0xFF1976D2)) }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Pferd: ${nennung.pferdName} (*${nennung.pferdAlter})", style = MaterialTheme.typography.bodyMedium)
|
||||
Text("Bewerbe: ${nennung.bewerbe}", style = MaterialTheme.typography.bodySmall, color = Color(0xFF2E7D32), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(nennung.email, style = MaterialTheme.typography.labelSmall, color = Color.Gray)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { /* Details */ }, shape = RoundedCornerShape(8.dp)) {
|
||||
Text("Details")
|
||||
}
|
||||
Button(
|
||||
onClick = onUebernehmen,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32))
|
||||
) {
|
||||
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Übernehmen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val PrimaryBlue = Color(0xFF1E3A8A)
|
||||
private val AccentBlue = Color(0xFF3B82F6)
|
||||
private val DeleteRed = Color(0xFFDC2626)
|
||||
|
||||
/**
|
||||
* ORGANISATION-Tab im TurnierDetailScreen.
|
||||
* Gemäß Figma Vision_03 (figma-entwurf_13 / figma-entwurf_14):
|
||||
* - Funktionäre & Offizielle (C-Satz): Turnierleiter, Turnierbeauftragter, Technischer Delegierter, Parcourschef
|
||||
* - Support-Team: Tierarzt, Schmied, Steward
|
||||
* - Richterkollegium: dynamische Liste (Name, Qualifikation, Funktion, Löschen)
|
||||
* - Austragungsplätze: dynamische Liste (Sparte, Größe, Bezeichnung, Löschen)
|
||||
*/
|
||||
@Composable
|
||||
fun OrganisationTabContent(viewModel: TurnierNennungViewModel) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
var turnierleiter by remember { mutableStateOf("") }
|
||||
var turnierbeauftragter by remember { mutableStateOf("") }
|
||||
var technischerDelegierter by remember { mutableStateOf("") }
|
||||
var parcourschef by remember { mutableStateOf("") }
|
||||
var tierarzt by remember { mutableStateOf("") }
|
||||
var schmied by remember { mutableStateOf("") }
|
||||
var steward by remember { mutableStateOf("") }
|
||||
|
||||
// --- Dropdown-States für die Suche ---
|
||||
var showTLDropdown by remember { mutableStateOf(false) }
|
||||
var showTBDropdown by remember { mutableStateOf(false) }
|
||||
var showTDDropdown by remember { mutableStateOf(false) }
|
||||
var showPCDropdown by remember { mutableStateOf(false) }
|
||||
var showTADropdown by remember { mutableStateOf(false) }
|
||||
var showSMDropdown by remember { mutableStateOf(false) }
|
||||
var showSTDropdown by remember { mutableStateOf(false) }
|
||||
|
||||
var richter by remember {
|
||||
mutableStateOf(
|
||||
listOf(
|
||||
RichterUiModel("Alexandra Schuster", "D-GP", "Hauptrichter"),
|
||||
RichterUiModel("Ulrike Knasmüller-Prinz", "D-M", "Beisitzer"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
var plaetze by remember {
|
||||
mutableStateOf(
|
||||
listOf(
|
||||
AustragungsplatzUiModel("Dressur", "20 x 60 m", "Hauptplatz"),
|
||||
AustragungsplatzUiModel("Dressur", "20 x 40 m", "Abreiteplatz 1"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// ── Funktionäre & Offizielle ─────────────────────────────────────────
|
||||
OrgSectionCard(title = "Funktionäre & Offizielle (C-Satz)") {
|
||||
OrgSubSection("Turnier-Organisation") {
|
||||
Box {
|
||||
OrgSearchField("Turnierleiter:", turnierleiter) {
|
||||
turnierleiter = it
|
||||
viewModel.searchFunktionaere(it)
|
||||
showTLDropdown = it.length >= 2
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showTLDropdown && state.searchResultsFunktionaere.isNotEmpty(),
|
||||
onDismissRequest = { showTLDropdown = false },
|
||||
properties = androidx.compose.ui.window.PopupProperties(focusable = false)
|
||||
) {
|
||||
state.searchResultsFunktionaere.forEach { f ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") },
|
||||
onClick = {
|
||||
turnierleiter = f.name
|
||||
showTLDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box {
|
||||
OrgSearchField("Turnierbeauftragter:", turnierbeauftragter) {
|
||||
turnierbeauftragter = it
|
||||
viewModel.searchFunktionaere(it)
|
||||
showTBDropdown = it.length >= 2
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showTBDropdown && state.searchResultsFunktionaere.isNotEmpty(),
|
||||
onDismissRequest = { showTBDropdown = false },
|
||||
properties = androidx.compose.ui.window.PopupProperties(focusable = false)
|
||||
) {
|
||||
state.searchResultsFunktionaere.forEach { f ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") },
|
||||
onClick = {
|
||||
turnierbeauftragter = f.name
|
||||
showTBDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box {
|
||||
OrgSearchField("Technischer Delegierter:", technischerDelegierter) {
|
||||
technischerDelegierter = it
|
||||
viewModel.searchFunktionaere(it)
|
||||
showTDDropdown = it.length >= 2
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showTDDropdown && state.searchResultsFunktionaere.isNotEmpty(),
|
||||
onDismissRequest = { showTDDropdown = false },
|
||||
properties = androidx.compose.ui.window.PopupProperties(focusable = false)
|
||||
) {
|
||||
state.searchResultsFunktionaere.forEach { f ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") },
|
||||
onClick = {
|
||||
technischerDelegierter = f.name
|
||||
showTDDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box {
|
||||
OrgSearchField("Parcourschef:", parcourschef) {
|
||||
parcourschef = it
|
||||
viewModel.searchFunktionaere(it)
|
||||
showPCDropdown = it.length >= 2
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showPCDropdown && state.searchResultsFunktionaere.isNotEmpty(),
|
||||
onDismissRequest = { showPCDropdown = false },
|
||||
properties = androidx.compose.ui.window.PopupProperties(focusable = false)
|
||||
) {
|
||||
state.searchResultsFunktionaere.forEach { f ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") },
|
||||
onClick = {
|
||||
parcourschef = f.name
|
||||
showPCDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OrgSubSection("Support-Team") {
|
||||
Box {
|
||||
OrgSearchField("Tierarzt:", tierarzt) {
|
||||
tierarzt = it
|
||||
viewModel.searchFunktionaere(it)
|
||||
showTADropdown = it.length >= 2
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showTADropdown && state.searchResultsFunktionaere.isNotEmpty(),
|
||||
onDismissRequest = { showTADropdown = false },
|
||||
properties = androidx.compose.ui.window.PopupProperties(focusable = false)
|
||||
) {
|
||||
state.searchResultsFunktionaere.forEach { f ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") },
|
||||
onClick = {
|
||||
tierarzt = f.name
|
||||
showTADropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box {
|
||||
OrgSearchField("Schmied:", schmied) {
|
||||
schmied = it
|
||||
viewModel.searchFunktionaere(it)
|
||||
showSMDropdown = it.length >= 2
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSMDropdown && state.searchResultsFunktionaere.isNotEmpty(),
|
||||
onDismissRequest = { showSMDropdown = false },
|
||||
properties = androidx.compose.ui.window.PopupProperties(focusable = false)
|
||||
) {
|
||||
state.searchResultsFunktionaere.forEach { f ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") },
|
||||
onClick = {
|
||||
schmied = f.name
|
||||
showSMDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box {
|
||||
OrgSearchField("Steward:", steward) {
|
||||
steward = it
|
||||
viewModel.searchFunktionaere(it)
|
||||
showSTDropdown = it.length >= 2
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSTDropdown && state.searchResultsFunktionaere.isNotEmpty(),
|
||||
onDismissRequest = { showSTDropdown = false },
|
||||
properties = androidx.compose.ui.window.PopupProperties(focusable = false)
|
||||
) {
|
||||
state.searchResultsFunktionaere.forEach { f ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") },
|
||||
onClick = {
|
||||
steward = f.name
|
||||
showSTDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Richterkollegium ─────────────────────────────────────────────────
|
||||
OrgSectionCard(
|
||||
title = "Richterkollegium",
|
||||
action = {
|
||||
TextButton(onClick = {
|
||||
richter = richter + RichterUiModel("", "", "")
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Richter hinzufügen", color = AccentBlue, fontSize = 13.sp)
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Tabellen-Header
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
|
||||
Text("Name", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f))
|
||||
Text("Qualifikation", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f))
|
||||
Text("Funktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f))
|
||||
Text("Aktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(48.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
richter.forEachIndexed { idx, r ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Name-Suche mit Dropdown
|
||||
var showRichterDropdown by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.weight(3f).padding(end = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = r.name,
|
||||
onValueChange = { v ->
|
||||
richter = richter.toMutableList().also { it[idx] = r.copy(name = v) }
|
||||
viewModel.searchFunktionaere(v)
|
||||
showRichterDropdown = v.length >= 2
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
singleLine = true,
|
||||
placeholder = { Text("Name suchen...", fontSize = 12.sp) }
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showRichterDropdown && state.searchResultsFunktionaere.isNotEmpty(),
|
||||
onDismissRequest = { showRichterDropdown = false },
|
||||
properties = androidx.compose.ui.window.PopupProperties(focusable = false)
|
||||
) {
|
||||
state.searchResultsFunktionaere.forEach { f ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${f.name} (${f.qualifikationen.joinToString(", ")})") },
|
||||
onClick = {
|
||||
richter = richter.toMutableList().also { it[idx] = r.copy(name = f.name) }
|
||||
showRichterDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Qualifikation-Dropdown
|
||||
var qualExpanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = r.qualifikation,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
DropdownMenu(expanded = qualExpanded, onDismissRequest = { qualExpanded = false }) {
|
||||
listOf("D-GP", "D-M", "D-L", "S-GP", "S-M").forEach { q ->
|
||||
DropdownMenuItem(text = { Text(q) }, onClick = {
|
||||
richter = richter.toMutableList().also { it[idx] = r.copy(qualifikation = q) }
|
||||
qualExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Funktion-Dropdown
|
||||
var funExpanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = r.funktion,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
DropdownMenu(expanded = funExpanded, onDismissRequest = { funExpanded = false }) {
|
||||
listOf("Hauptrichter", "Beisitzer", "Schreiber").forEach { f ->
|
||||
DropdownMenuItem(text = { Text(f) }, onClick = {
|
||||
richter = richter.toMutableList().also { it[idx] = r.copy(funktion = f) }
|
||||
funExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { richter = richter.toMutableList().also { it.removeAt(idx) } },
|
||||
modifier = Modifier.size(44.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Löschen",
|
||||
tint = DeleteRed,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Austragungsplätze ────────────────────────────────────────────────
|
||||
OrgSectionCard(
|
||||
title = "Austragungsplätze",
|
||||
) {
|
||||
OrgSubSection(
|
||||
title = "Plätze & Anlagen",
|
||||
action = {
|
||||
TextButton(onClick = {
|
||||
plaetze = plaetze + AustragungsplatzUiModel("", "", "")
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Platz hinzufügen", color = AccentBlue, fontSize = 13.sp)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
|
||||
Text("Sparte", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f))
|
||||
Text("Größe", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f))
|
||||
Text("Bezeichnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f))
|
||||
Text("Aktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(48.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
plaetze.forEachIndexed { idx, p ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
var sparteExpanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = p.sparte,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
DropdownMenu(expanded = sparteExpanded, onDismissRequest = { sparteExpanded = false }) {
|
||||
listOf("Dressur", "Springen", "Vielseitigkeit").forEach { s ->
|
||||
DropdownMenuItem(text = { Text(s) }, onClick = {
|
||||
plaetze = plaetze.toMutableList().also { it[idx] = p.copy(sparte = s) }
|
||||
sparteExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
var groesseExpanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = p.groesse,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
DropdownMenu(expanded = groesseExpanded, onDismissRequest = { groesseExpanded = false }) {
|
||||
listOf("20 x 60 m", "20 x 40 m", "60 x 80 m").forEach { g ->
|
||||
DropdownMenuItem(text = { Text(g) }, onClick = {
|
||||
plaetze = plaetze.toMutableList().also { it[idx] = p.copy(groesse = g) }
|
||||
groesseExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = p.bezeichnung,
|
||||
onValueChange = { v -> plaetze = plaetze.toMutableList().also { it[idx] = p.copy(bezeichnung = v) } },
|
||||
modifier = Modifier.weight(3f).height(44.dp).padding(end = 8.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
IconButton(
|
||||
onClick = { plaetze = plaetze.toMutableList().also { it.removeAt(idx) } },
|
||||
modifier = Modifier.size(44.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Löschen",
|
||||
tint = DeleteRed,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Speichern ────────────────────────────────────────────────────────
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(
|
||||
onClick = {},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
|
||||
) { Text("Speichern") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OrgSectionCard(
|
||||
title: String,
|
||||
action: @Composable (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
|
||||
action?.invoke()
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OrgSubSection(
|
||||
title: String,
|
||||
action: @Composable (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF9FAFB)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
action?.invoke()
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OrgSearchField(label: String, value: String, onValueChange: (String) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.weight(1.5f), // Flexibles Gewicht statt fixen 200dp
|
||||
color = Color(0xFF374151)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text("Name suchen...", fontSize = 12.sp) },
|
||||
modifier = Modifier.weight(3f), // Flexibles Gewicht und keine fixe Höhe
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- UI-Modelle ---
|
||||
|
||||
data class RichterUiModel(val name: String, val qualifikation: String, val funktion: String)
|
||||
data class AustragungsplatzUiModel(val sparte: String, val groesse: String, val bezeichnung: String)
|
||||
+630
@@ -0,0 +1,630 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.time.LocalDate
|
||||
|
||||
private val PrimaryBlue = Color(0xFF1E3A8A)
|
||||
private val AccentBlue = Color(0xFF3B82F6)
|
||||
|
||||
/**
|
||||
* STAMMDATEN-Tab im TurnierDetailScreen.
|
||||
* Gemäß Figma Vision_03 (figma-entwurf_16 / figma-entwurf_15):
|
||||
* - Turnier-Konfiguration: Nr., Typ (OTO/FEI), ZNS-Import, Sprache
|
||||
* - Sparten-Checkboxen, Klassen, Kategorien, Datum
|
||||
* - Turnier-Beschreibung: Titel, Sub-Titel
|
||||
* - Sponsoren
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StammdatenTabContent(
|
||||
turnierId: Long,
|
||||
eventVon: String? = null,
|
||||
eventBis: String? = null,
|
||||
eventOrt: String? = null,
|
||||
veranstalterName: String? = null,
|
||||
veranstalterOrt: String? = null,
|
||||
veranstalterBundesland: String? = null,
|
||||
veranstalterLogoUrl: String? = null,
|
||||
) {
|
||||
// In einer echten App würden wir diese Daten aus einem ViewModel laden.
|
||||
// Hier simulieren wir den State basierend auf den Anforderungen.
|
||||
|
||||
var turnierNr by remember { mutableStateOf("") }
|
||||
var nrConfirmed by remember { mutableStateOf(false) }
|
||||
var showNrConfirm by remember { mutableStateOf(false) }
|
||||
var znsDataLoaded by remember { mutableStateOf(false) }
|
||||
var znsPayloadVersion by remember { mutableStateOf<String?>(null) }
|
||||
var znsImportedAt by remember { mutableStateOf<String?>(null) }
|
||||
val znsImportHistory =
|
||||
remember { mutableStateListOf<Triple<String, String, Boolean>>() } // (source, payloadVersion, ok)
|
||||
var typ by remember { mutableStateOf("ÖTO (National)") }
|
||||
|
||||
val sparten = remember { mutableStateListOf<String>() }
|
||||
val klassen = remember { mutableStateListOf<String>() }
|
||||
val kat = remember { mutableStateListOf<String>() }
|
||||
|
||||
var von by remember { mutableStateOf(eventVon ?: "") }
|
||||
var bis by remember { mutableStateOf(eventBis ?: "") }
|
||||
var ort by remember { mutableStateOf(eventOrt ?: "") }
|
||||
|
||||
var titel by remember { mutableStateOf("") }
|
||||
var subTitel by remember { mutableStateOf("") }
|
||||
|
||||
// Initialisierung aus Repository
|
||||
LaunchedEffect(turnierId) {
|
||||
// In einer echten Architektur kommt dies über das Repository.
|
||||
// Aber für die Demo/Fakten-Präsentation im Desktop-Shell-Kontext:
|
||||
try {
|
||||
val clazz = Class.forName("at.mocode.frontend.shell.desktop.data.TurnierStore")
|
||||
val method = clazz.getMethod("allTurniere")
|
||||
val all = method.invoke(null) as? List<*>
|
||||
val turnier = all?.find { t ->
|
||||
val idField = t!!::class.java.getDeclaredField("turnierNr")
|
||||
idField.isAccessible = true
|
||||
idField.get(t).toString() == turnierId.toString() ||
|
||||
t.hashCode().toLong() == turnierId // Fallback, falls die ID anders gemappt ist
|
||||
}
|
||||
|
||||
when {
|
||||
turnier != null -> {
|
||||
val tClass = turnier::class.java
|
||||
|
||||
val nrField = tClass.getDeclaredField("turnierNr")
|
||||
nrField.isAccessible = true
|
||||
turnierNr = nrField.get(turnier).toString()
|
||||
nrConfirmed = true
|
||||
|
||||
val titelField = tClass.getDeclaredField("titel")
|
||||
titelField.isAccessible = true
|
||||
titel = titelField.get(turnier) as String
|
||||
|
||||
val subField = tClass.getDeclaredField("subTitel")
|
||||
subField.isAccessible = true
|
||||
subTitel = subField.get(turnier) as String
|
||||
|
||||
val katField = tClass.getDeclaredField("kategorie")
|
||||
katField.isAccessible = true
|
||||
val kats = katField.get(turnier) as? List<String>
|
||||
kats?.let {
|
||||
kat.clear()
|
||||
kat.addAll(it)
|
||||
}
|
||||
|
||||
val typField = tClass.getDeclaredField("typ")
|
||||
typField.isAccessible = true
|
||||
typ = typField.get(turnier) as String
|
||||
|
||||
val znsField = tClass.getDeclaredField("znsDataLoaded")
|
||||
znsField.isAccessible = true
|
||||
znsDataLoaded = znsField.get(turnier) as Boolean
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Reflection fehlgeschlagen oder Store nicht erreichbar -> Fallback auf leere Felder
|
||||
}
|
||||
}
|
||||
var turnierLogoUrl by remember { mutableStateOf("") }
|
||||
val sponsoren = remember { mutableStateListOf<String>() }
|
||||
|
||||
var showZnsDialog by remember { mutableStateOf(false) }
|
||||
var showZnsLog by remember { mutableStateOf(false) }
|
||||
|
||||
// Hilf's-States für DatePicker
|
||||
var showDatePickerVon by remember { mutableStateOf(false) }
|
||||
var showDatePickerBis by remember { mutableStateOf(false) }
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// ── Turnier-Konfiguration (Schritt 1 Logik) ───────────────────────────
|
||||
SectionCard(title = "Turnier-Konfiguration & ZNS") {
|
||||
FormRow("Turnier-Nr.:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = turnierNr,
|
||||
onValueChange = { if (it.length <= 5 && it.all { c -> c.isDigit() }) turnierNr = it },
|
||||
placeholder = { Text("5-stellig", fontSize = 13.sp) },
|
||||
modifier = Modifier.width(120.dp),
|
||||
singleLine = true,
|
||||
enabled = !nrConfirmed
|
||||
)
|
||||
when {
|
||||
!nrConfirmed -> {
|
||||
Button(
|
||||
onClick = { showNrConfirm = true },
|
||||
enabled = turnierNr.length == 5,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
|
||||
) {
|
||||
Text("Bestätigen")
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = { },
|
||||
label = { Text("Bestätigt") },
|
||||
trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
when (turnierNr.length) {
|
||||
5 if !nrConfirmed -> {
|
||||
Text(
|
||||
"Bitte Turnier-Nummer bestätigen um fortzufahren.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FormRow("Typ:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
FilterChip(
|
||||
selected = typ == "ÖTO (National)",
|
||||
onClick = { typ = "ÖTO (National)" },
|
||||
enabled = nrConfirmed,
|
||||
label = { Text("ÖTO (National)") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = typ == "FEI (International)",
|
||||
onClick = { typ = "FEI (International)" },
|
||||
enabled = nrConfirmed,
|
||||
label = { Text("FEI (International)") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FormRow("ZNS-Stammdaten:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(
|
||||
onClick = { showZnsDialog = true },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue), enabled = nrConfirmed
|
||||
) {
|
||||
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Import via Internet")
|
||||
}
|
||||
OutlinedButton(onClick = { showZnsDialog = true }, enabled = nrConfirmed) {
|
||||
Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Import via USB")
|
||||
}
|
||||
TextButton(onClick = { showZnsLog = true }, enabled = nrConfirmed) { Text("Import-Log anzeigen…") }
|
||||
}
|
||||
|
||||
val znsStatusColor = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(
|
||||
if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = znsStatusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
if (znsDataLoaded) "ZNS-Daten geladen" else "Keine ZNS-Daten geladen",
|
||||
color = znsStatusColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
if (znsDataLoaded) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
listOfNotNull(
|
||||
znsPayloadVersion?.let { "Version: $it" },
|
||||
znsImportedAt?.let { "Zeit: $it" },
|
||||
).joinToString(" • "),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sparten & Kategorien (Schritt 2 Logik) ───────────────────────────
|
||||
SectionCard(title = "Reglement & Sparten") {
|
||||
FormRow("Sparte:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = sparten.contains("Dressur"),
|
||||
onClick = { if (sparten.contains("Dressur")) sparten.remove("Dressur") else sparten.add("Dressur") },
|
||||
enabled = nrConfirmed,
|
||||
label = { Text("Dressur") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = sparten.contains("Springen"),
|
||||
onClick = { if (sparten.contains("Springen")) sparten.remove("Springen") else sparten.add("Springen") },
|
||||
enabled = nrConfirmed,
|
||||
label = { Text("Springen") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FormRow("Klasse:") {
|
||||
val klassenListe = listOf("C-NEU", "C", "B", "A")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
klassenListe.forEach { k ->
|
||||
FilterChip(
|
||||
selected = klassen.contains(k),
|
||||
onClick = { if (klassen.contains(k)) klassen.remove(k) else klassen.add(k) },
|
||||
enabled = nrConfirmed,
|
||||
label = { Text(k) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FormRow("Kategorien:") {
|
||||
// Logik zur Generierung der Kategorien
|
||||
val suggested = mutableListOf<String>()
|
||||
sparten.forEach { s ->
|
||||
val prefix = if (s == "Dressur") "CDN" else "CSN"
|
||||
klassen.forEach { k ->
|
||||
suggested.add("$prefix-$k")
|
||||
suggested.add("${prefix}P-$k") // Pony Variante
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
suggested.isEmpty() -> {
|
||||
Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Gruppiere nach Sparte (CDN/CSN)
|
||||
val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" }
|
||||
grouped.forEach { (gruppe, eintraege) ->
|
||||
Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
eintraege.sorted().forEach { c ->
|
||||
InputChip(
|
||||
selected = kat.contains(c),
|
||||
onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) },
|
||||
enabled = nrConfirmed,
|
||||
label = { Text(c) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FormRow("Zeitraum:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
val vonMod =
|
||||
if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp)
|
||||
OutlinedTextField(
|
||||
value = von,
|
||||
onValueChange = {},
|
||||
label = { Text("Von") },
|
||||
modifier = vonMod,
|
||||
readOnly = true,
|
||||
enabled = nrConfirmed,
|
||||
trailingIcon = { Icon(Icons.Default.DateRange, null) }
|
||||
)
|
||||
Text("bis")
|
||||
val bisMod =
|
||||
if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp)
|
||||
OutlinedTextField(
|
||||
value = bis,
|
||||
onValueChange = {},
|
||||
label = { Text("Bis") },
|
||||
modifier = bisMod,
|
||||
readOnly = true,
|
||||
enabled = nrConfirmed,
|
||||
trailingIcon = { Icon(Icons.Default.DateRange, null) }
|
||||
)
|
||||
}
|
||||
val rangeText =
|
||||
if (eventVon != null && eventBis != null) "Muss zwischen $eventVon – $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen."
|
||||
Text(rangeText, fontSize = 11.sp, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Branding (Schritt 3 Logik) ───────────────────────────────────────
|
||||
SectionCard(title = "Turnier-Branding") {
|
||||
// Default-Titel-Vorschlag: [Kategorien] [Verein-Ort] [Bundesland]
|
||||
val defaultTitle = remember(kat.size, veranstalterOrt, veranstalterBundesland) {
|
||||
val cats = if (kat.isEmpty()) "" else kat.sorted().joinToString(" ")
|
||||
listOfNotNull(
|
||||
cats.ifBlank { null },
|
||||
listOfNotNull(veranstalterOrt, veranstalterBundesland).filter { it.isNotBlank() }.joinToString(" ")
|
||||
.takeIf { it.isNotBlank() }
|
||||
).joinToString(" ")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = titel,
|
||||
onValueChange = { titel = it },
|
||||
label = { Text("Titel") },
|
||||
placeholder = { if (defaultTitle.isNotBlank()) Text(defaultTitle) },
|
||||
enabled = nrConfirmed,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = subTitel,
|
||||
onValueChange = { subTitel = it },
|
||||
label = { Text("Sub-Titel") },
|
||||
enabled = nrConfirmed,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Ort im Branding-Bereich platzieren (mit Soft-Warnung bei Abweichung zum Veranstaltungsort)
|
||||
FormRow("Ort:") {
|
||||
OutlinedTextField(
|
||||
value = ort,
|
||||
onValueChange = { ort = it },
|
||||
label = { Text("Austragungsort") },
|
||||
enabled = nrConfirmed,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
supportingText = {
|
||||
if (eventOrt != null && ort.isNotBlank() && ort.trim() != eventOrt.trim()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFF59E0B),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Abweichung zum Veranstaltungsort ($eventOrt) – bitte prüfen.", color = Color(0xFFF59E0B))
|
||||
}
|
||||
} else {
|
||||
Text("Muss mit Veranstaltungsort übereinstimmen.")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Turnier-Logo mit Fallback auf Veranstalterlogo
|
||||
OutlinedTextField(
|
||||
value = turnierLogoUrl,
|
||||
onValueChange = { turnierLogoUrl = it },
|
||||
label = { Text("Turnier-Logo (URL/Pfad)") },
|
||||
enabled = nrConfirmed,
|
||||
supportingText = {
|
||||
Text("Wenn leer: verwende Veranstalter-Logo${if (!veranstalterLogoUrl.isNullOrBlank()) " ($veranstalterLogoUrl)" else ""}.")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
FormRow("Sponsoren:") {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
sponsoren.forEach { s ->
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = { sponsoren.remove(s) },
|
||||
enabled = nrConfirmed,
|
||||
label = { Text(s) },
|
||||
trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(14.dp)) }
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { sponsoren.add("Neuer Sponsor") }, enabled = nrConfirmed) {
|
||||
Text("+ Hinzufügen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer ──────────────────────────────────────────────────────────
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Save-Enable-Matrix (kleine Checkliste)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
AssistChip(onClick = {}, label = { Text("Nr bestätigt") }, leadingIcon = {
|
||||
Icon(
|
||||
if (nrConfirmed) Icons.Default.Check else Icons.Default.Close,
|
||||
null,
|
||||
tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
||||
)
|
||||
})
|
||||
AssistChip(onClick = {}, label = { Text("ZNS geladen") }, leadingIcon = {
|
||||
Icon(
|
||||
if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close,
|
||||
null,
|
||||
tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
||||
)
|
||||
})
|
||||
val dateOk = remember(von, bis, eventVon, eventBis) {
|
||||
try {
|
||||
if (eventVon == null || eventBis == null || von.isBlank()) true else {
|
||||
val evV = LocalDate.parse(eventVon)
|
||||
val evB = LocalDate.parse(eventBis)
|
||||
val tV = LocalDate.parse(von)
|
||||
val tB = if (bis.isBlank()) tV else LocalDate.parse(bis)
|
||||
!tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
AssistChip(onClick = {}, label = { Text("Datum gültig") }, leadingIcon = {
|
||||
Icon(
|
||||
if (dateOk) Icons.Default.Check else Icons.Default.Close,
|
||||
null,
|
||||
tint = if (dateOk) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { /* Speichern */ },
|
||||
enabled = run {
|
||||
val base = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank()
|
||||
val dateValid = try {
|
||||
if (eventVon == null || eventBis == null || von.isBlank()) true else {
|
||||
val evV = LocalDate.parse(eventVon)
|
||||
val evB = LocalDate.parse(eventBis)
|
||||
val tV = LocalDate.parse(von)
|
||||
val tB = if (bis.isBlank()) tV else LocalDate.parse(bis)
|
||||
!tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
base && dateValid
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Save, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Änderungen speichern")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog-Simulationen
|
||||
when {
|
||||
showZnsDialog -> {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showZnsDialog = false },
|
||||
title = { Text("ZNS Import") },
|
||||
text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
znsDataLoaded = true
|
||||
znsPayloadVersion = "v2.4"
|
||||
znsImportedAt = java.time.Instant.now().toString()
|
||||
znsImportHistory.add(Triple("Internet/USB", znsPayloadVersion!!, true))
|
||||
showZnsDialog = false
|
||||
}) { Text("Importieren") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
showNrConfirm -> {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showNrConfirm = false },
|
||||
title = { Text("Turnier-Nummer bestätigen?") },
|
||||
text = { Text("Die Turnier-Nr. ist nach der Bestätigung nicht mehr änderbar.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { nrConfirmed = true; showNrConfirm = false }) { Text("Ja, bestätigen") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showNrConfirm = false }) { Text("Abbrechen") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
showZnsLog -> {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showZnsLog = false },
|
||||
title = { Text("ZNS Import-Log (letzte 5)") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (znsImportHistory.isEmpty()) {
|
||||
Text("Keine Einträge vorhanden.", color = Color.Gray)
|
||||
} else {
|
||||
znsImportHistory.takeLast(5).asReversed().forEach { (src, ver, ok) ->
|
||||
val c = if (ok) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
||||
Text("• $src – Version $ver – ${if (ok) "OK" else "Fehler"}", color = c, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { showZnsLog = false }) { Text("Schließen") } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
showDatePickerVon -> {
|
||||
val state = rememberDatePickerState()
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showDatePickerVon = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
state.selectedDateMillis?.let {
|
||||
von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
|
||||
}
|
||||
showDatePickerVon = false
|
||||
}) { Text("OK") }
|
||||
}
|
||||
) { DatePicker(state) }
|
||||
}
|
||||
|
||||
showDatePickerBis -> {
|
||||
val state = rememberDatePickerState()
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showDatePickerBis = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
state.selectedDateMillis?.let {
|
||||
bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
|
||||
}
|
||||
showDatePickerBis = false
|
||||
}) { Text("OK") }
|
||||
}
|
||||
) { DatePicker(state) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = PrimaryBlue, fontWeight = FontWeight.Bold)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormRow(label: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.width(140.dp).padding(top = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile
|
||||
import at.mocode.frontend.features.turnier.domain.Bewerb
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
private val SlBlue = Color(0xFF1E3A8A)
|
||||
private val SlHeaderBg = Color(0xFFF1F5F9)
|
||||
|
||||
/**
|
||||
* STARTLISTEN-Tab gemäß Vision_03.
|
||||
*
|
||||
* Layout: 2-spaltig
|
||||
* - Links (flex): Bewerbs-Tabs + Starter-Tabelle (Startnr | Pferd | Reiter | Abteilung | Beginn)
|
||||
* - Rechts (280dp): Sortierung & Zeit-Panel
|
||||
*/
|
||||
@Composable
|
||||
fun StartlistenTabContent(
|
||||
viewModel: BewerbViewModel = koinInject()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val selectedBewerb = state.list.find { it.id == state.selectedId }
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Linke Spalte: Bewerbs-Tabs + Tabelle ─────────────────────────────
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
StartlistenBewerbsTabs(
|
||||
bewerbe = state.list,
|
||||
selectedId = state.selectedId,
|
||||
onSelect = { viewModel.send(BewerbIntent.Select(it)) },
|
||||
currentStartliste = state.currentStartliste,
|
||||
onGenerate = { viewModel.generateStartliste() },
|
||||
onRowClick = { viewModel.send(BewerbIntent.OpenErgebnisEdit(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// ── Rechte Spalte: Sortierung & Zeit ─────────────────────────────────
|
||||
StartlistenSortierPanel(modifier = Modifier.width(280.dp).fillMaxHeight())
|
||||
}
|
||||
|
||||
// Ergebnis-Dialog
|
||||
state.editingErgebnis?.let { ergebnis ->
|
||||
val zeile = state.selectedZeile
|
||||
if (zeile != null) {
|
||||
ErgebnisEditDialog(
|
||||
ergebnis = ergebnis,
|
||||
reiterName = zeile.reiter,
|
||||
pferdName = zeile.pferd,
|
||||
onDismiss = { viewModel.send(BewerbIntent.CloseErgebnisEdit) },
|
||||
onSave = { viewModel.send(BewerbIntent.SaveErgebnis(it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartlistenBewerbsTabs(
|
||||
bewerbe: List<Bewerb>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long?) -> Unit,
|
||||
currentStartliste: List<StartlistenZeile>,
|
||||
onGenerate: () -> Unit,
|
||||
onRowClick: (StartlistenZeile) -> Unit
|
||||
) {
|
||||
val selectedIndex = bewerbe.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
|
||||
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedIndex,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = SlBlue,
|
||||
edgePadding = 0.dp,
|
||||
) {
|
||||
bewerbe.forEachIndexed { index, bewerb ->
|
||||
Tab(
|
||||
selected = selectedId == bewerb.id,
|
||||
onClick = { onSelect(bewerb.id) },
|
||||
text = { Text(bewerb.tag, fontSize = 12.sp) },
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
val selectedBewerb = bewerbe.getOrNull(selectedIndex)
|
||||
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = selectedBewerb?.let { "${it.tag} – ${it.name}" } ?: "Kein Bewerb ausgewählt",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp)
|
||||
) {
|
||||
Text("Drucken", fontSize = 12.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp)
|
||||
) {
|
||||
Text("Exportieren", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// Tabellen-Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(SlHeaderBg).padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
|
||||
Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text("Abteilung", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp))
|
||||
Text("Beginn", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
if (currentStartliste.isEmpty()) {
|
||||
// Leere Liste
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Keine Starter vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Startliste wird nach Nennungsschluss generiert.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onGenerate,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
|
||||
) {
|
||||
Text("Startliste generieren", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Liste anzeigen
|
||||
androidx.compose.foundation.lazy.LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(currentStartliste.size) { index ->
|
||||
val zeile = currentStartliste[index]
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onRowClick(zeile) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(zeile.nr.toString(), fontSize = 12.sp, modifier = Modifier.width(70.dp))
|
||||
Text(zeile.pferd, fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
Text(zeile.reiter, fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
Text("-", fontSize = 12.sp, modifier = Modifier.width(80.dp))
|
||||
Text(zeile.zeit, fontSize = 12.sp, modifier = Modifier.width(70.dp))
|
||||
}
|
||||
HorizontalDivider(color = Color(0xFFE5E7EB))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartlistenSortierPanel(modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Sortierung & Zeit", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
HorizontalDivider()
|
||||
|
||||
// Sortierung
|
||||
Text("Sortierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SortierOption("Aufsteigend (Startnummer)")
|
||||
SortierOption("Absteigend (Startnummer)")
|
||||
SortierOption("Auslosung (zufällig)")
|
||||
SortierOption("Alphabetisch (Pferd)")
|
||||
SortierOption("Alphabetisch (Reiter)")
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Zeiten
|
||||
Text("Zeiten", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
|
||||
LabeledInput("Beginnzeit:", "08:00", "(hh:mm)")
|
||||
LabeledInput("Reitdauer:", "02:00", "(mm:ss)")
|
||||
LabeledInput("Umbau:", "10", "(mm)")
|
||||
LabeledInput("Besichtigung:", "10", "(mm)")
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Button(
|
||||
onClick = {},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Zeiten neu berechnen", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortierOption(label: String) {
|
||||
var selected by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = { selected = !selected })
|
||||
Text(label, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabeledInput(label: String, value: String, unit: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, fontSize = 12.sp, modifier = Modifier.width(100.dp))
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(70.dp),
|
||||
singleLine = true,
|
||||
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(unit, fontSize = 11.sp, color = Color(0xFF6B7280))
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import at.mocode.frontend.features.turnier.domain.Turnier
|
||||
import at.mocode.frontend.features.turnier.domain.TurnierRepository
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
repo.list()
|
||||
.onSuccess { list ->
|
||||
val items = list.map { it.toListItem() }
|
||||
reduce { cur ->
|
||||
val filtered = filterList(items, cur.searchQuery)
|
||||
cur.copy(isLoading = false, list = items, filtered = filtered)
|
||||
}
|
||||
}
|
||||
.onFailure { t ->
|
||||
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Turnier.toListItem() = TurnierListItem(
|
||||
id = id,
|
||||
name = name,
|
||||
ort = "Stadl-Paura", // Platzhalter bis API erweitert
|
||||
startDatum = "2026-05-01",
|
||||
endDatum = "2026-05-03",
|
||||
status = "AKTIV"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
package at.mocode.frontend.features.turnier.presentation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val ZeitplanBlue = Color(0xFF1E3A8A)
|
||||
private val ZeitplanBg = Color(0xFFF8FAFC)
|
||||
private val SlotBorder = Color(0xFFE2E8F0)
|
||||
private val HourLabelColor = Color(0xFF64748B)
|
||||
|
||||
// Konfiguration für den Zeitstrahl
|
||||
private const val START_HOUR = 7
|
||||
private const val END_HOUR = 20
|
||||
private val HOUR_HEIGHT = 80.dp
|
||||
private val MINUTE_HEIGHT = HOUR_HEIGHT / 60
|
||||
|
||||
/**
|
||||
* ZEITPLAN-Tab gemäß Konzept „Zeitplan-Optimierung“.
|
||||
*
|
||||
* Visuelle Kalender-Ansicht mit Drag & Drop Support.
|
||||
*/
|
||||
@Composable
|
||||
fun ZeitplanTabContent(
|
||||
turnierId: Long,
|
||||
viewModel: BewerbViewModel
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val items = state.filtered.map { bewerb ->
|
||||
val startMin = if (bewerb.beginnZeit != null) {
|
||||
val parts = bewerb.beginnZeit.split(":")
|
||||
parts[0].toInt() * 60 + parts[1].toInt()
|
||||
} else {
|
||||
7 * 60 // Default 07:00 wenn nichts gesetzt
|
||||
}
|
||||
|
||||
ZeitplanItemUi(
|
||||
id = bewerb.id,
|
||||
nummer = bewerb.tag.filter { it.isDigit() }.toIntOrNull() ?: 0,
|
||||
name = bewerb.name,
|
||||
startMinutes = startMin,
|
||||
durationMinutes = bewerb.reitdauerMinuten ?: 60,
|
||||
color = when (bewerb.sparte) {
|
||||
"DRESSUR" -> Color(0xFF1E3A8A)
|
||||
"SPRINGEN" -> Color(0xFF059669)
|
||||
else -> ZeitplanBlue
|
||||
},
|
||||
hasConflict = bewerb.warnungen.isNotEmpty(),
|
||||
conflictMessage = bewerb.warnungen.joinToString("\n") { it.nachricht }
|
||||
)
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
var showAuditLog by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(ZeitplanBg)) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Header / Toolbar
|
||||
ZeitplanToolbar(viewModel = viewModel, onShowHistory = { showAuditLog = !showAuditLog })
|
||||
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
// Zeit-Achse (feststehend)
|
||||
ZeitAchse()
|
||||
|
||||
// Content (scrollbar)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
// Hintergrund-Gitter
|
||||
ZeitplanGitter()
|
||||
|
||||
// Bewerbe / Blöcke
|
||||
items.forEach { item ->
|
||||
DraggableBewerbBox(
|
||||
item = item,
|
||||
onPositionChange = { newMinutes ->
|
||||
val h = newMinutes / 60
|
||||
val m = newMinutes % 60
|
||||
val timeStr = "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}"
|
||||
viewModel.send(BewerbIntent.UpdateZeitplan(item.id, timeStr))
|
||||
},
|
||||
onClick = {
|
||||
viewModel.send(BewerbIntent.Select(item.id))
|
||||
viewModel.send(BewerbIntent.LoadAuditLog(item.id))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAuditLog) {
|
||||
VerticalDivider(color = SlotBorder)
|
||||
AuditLogSektion(
|
||||
state = state,
|
||||
modifier = Modifier.width(300.dp).fillMaxHeight()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showExportDialog && state.exportContent != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.send(BewerbIntent.CloseExportDialog) },
|
||||
title = { Text("ZNS B-Satz Export") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Der Export für den ZNS B-Satz wurde generiert. Kopiere den Inhalt in deine n2-Datei.")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val content = state.exportContent ?: ""
|
||||
OutlinedTextField(
|
||||
value = content,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier.fillMaxWidth().height(200.dp),
|
||||
textStyle = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { viewModel.send(BewerbIntent.CloseExportDialog) }) {
|
||||
Text("Schließen")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZeitplanToolbar(
|
||||
viewModel: BewerbViewModel,
|
||||
onShowHistory: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Zeitplan-Optimierung", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = ZeitplanBlue)
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
TextButton(onClick = onShowHistory) {
|
||||
Text("Historie anzeigen", color = ZeitplanBlue, fontSize = 13.sp)
|
||||
}
|
||||
|
||||
// Platz-Filter (Mock)
|
||||
Text("Platz:", fontSize = 13.sp)
|
||||
AssistChip(onClick = {}, label = { Text("Hauptplatz") })
|
||||
AssistChip(onClick = {}, label = { Text("Viereck 1") }, leadingIcon = { Text("✓", fontSize = 12.sp) })
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.send(BewerbIntent.ExportZnsBSatz) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = ZeitplanBlue)
|
||||
) {
|
||||
Text("B-Satz Export (ZNS)", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuditLogSektion(
|
||||
state: BewerbState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.background(Color.White).padding(16.dp)) {
|
||||
Text(
|
||||
text = "ÄNDERUNGS-HISTORIE",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = HourLabelColor
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (state.selectedId == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Wähle einen Bewerb aus,\num die Historie zu sehen.", color = HourLabelColor, fontSize = 12.sp)
|
||||
}
|
||||
} else if (state.isAuditLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = ZeitplanBlue)
|
||||
}
|
||||
} else if (state.auditLog.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Änderungen erfasst.", color = HourLabelColor, fontSize = 12.sp)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items(state.auditLog) { entry ->
|
||||
AuditLogItem(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuditLogItem(entry: at.mocode.frontend.features.turnier.domain.AuditLogEntry) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(ZeitplanBg, RoundedCornerShape(4.dp))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(6.dp).background(ZeitplanBlue, RoundedCornerShape(3.dp)))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = entry.action,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Black
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
text = entry.timestamp.split("T").lastOrNull()?.take(5) ?: "",
|
||||
fontSize = 10.sp,
|
||||
color = HourLabelColor
|
||||
)
|
||||
}
|
||||
if (entry.changesJson != null) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = entry.changesJson,
|
||||
fontSize = 10.sp,
|
||||
color = Color.DarkGray,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZeitAchse() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(60.dp)
|
||||
.fillMaxHeight()
|
||||
.background(Color.White)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxHeight().width(59.dp).background(Color.White)) {
|
||||
Column {
|
||||
for (hour in START_HOUR..END_HOUR) {
|
||||
Box(
|
||||
modifier = Modifier.height(HOUR_HEIGHT).fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Text(
|
||||
text = "${hour.toString().padStart(2, '0')}:00",
|
||||
fontSize = 11.sp,
|
||||
color = HourLabelColor,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZeitplanGitter() {
|
||||
Column {
|
||||
for (hour in START_HOUR..END_HOUR) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(HOUR_HEIGHT)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter), color = SlotBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DraggableBewerbBox(
|
||||
item: ZeitplanItemUi,
|
||||
onPositionChange: (Int) -> Unit,
|
||||
onClick: () -> Unit = {}
|
||||
) {
|
||||
// Berechnung der Position basierend auf den Startminuten seit START_HOUR
|
||||
val relativeMinutes = item.startMinutes - (START_HOUR * 60)
|
||||
val topOffset = (relativeMinutes * MINUTE_HEIGHT.value).dp
|
||||
val height = (item.durationMinutes * MINUTE_HEIGHT.value).dp
|
||||
|
||||
var offsetX by remember { mutableStateOf(0f) }
|
||||
var offsetY by remember { mutableStateOf(0f) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(y = topOffset)
|
||||
.padding(horizontal = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(item.color.copy(alpha = 0.15f))
|
||||
.border(1.dp, item.color, RoundedCornerShape(6.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragEnd = {
|
||||
// Snapping auf 5 Minuten Intervalle
|
||||
val movedMinutes = (offsetY / MINUTE_HEIGHT.toPx()).roundToInt()
|
||||
val newTotalMinutes = item.startMinutes + movedMinutes
|
||||
val snappedMinutes = (newTotalMinutes / 5) * 5
|
||||
|
||||
onPositionChange(snappedMinutes)
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
// Nur vertikales Dragging für den Zeitplan vorerst
|
||||
offsetY += dragAmount.y
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = item.timeString,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = item.color
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Bewerb ${item.nummer}",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.name,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
color = Color.DarkGray
|
||||
)
|
||||
if (item.hasConflict) {
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("⚠️", fontSize = 12.sp)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = item.conflictMessage.ifEmpty { "Konflikt" },
|
||||
fontSize = 10.sp,
|
||||
color = Color.Red,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ZeitplanItemUi(
|
||||
val id: Long,
|
||||
val nummer: Int,
|
||||
val name: String,
|
||||
val startMinutes: Int, // Minuten seit 00:00
|
||||
val durationMinutes: Int,
|
||||
val color: Color = ZeitplanBlue,
|
||||
val hasConflict: Boolean = false,
|
||||
val conflictMessage: String = ""
|
||||
) {
|
||||
val timeString: String
|
||||
get() {
|
||||
val h = startMinutes / 60
|
||||
val m = startMinutes % 60
|
||||
return "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun sampleZeitplanItems() = listOf(
|
||||
ZeitplanItemUi(1, 1, "Dressurreiterprüfung Reiterpass", 8 * 60, 45),
|
||||
ZeitplanItemUi(2, 2, "Dressurreiterprüfung Reitenadel", 8 * 60 + 50, 60, hasConflict = true),
|
||||
ZeitplanItemUi(3, 3, "Dressurprüfung Kl. A (Aufgabe A2)", 10 * 60 + 30, 90, color = Color(0xFF059669)),
|
||||
ZeitplanItemUi(4, 4, "Mittagspause", 12 * 60 + 30, 45, color = Color(0xFFD97706)),
|
||||
ZeitplanItemUi(5, 5, "Dressurreiterprüfung Kl. L", 13 * 60 + 30, 120, color = Color(0xFF7C3AED)),
|
||||
)
|
||||
Reference in New Issue
Block a user