feat(core+frontend+domain): add ZNS Bewerb parser and integrate start list feature

- **Parser Implementation:**
  - Introduced `ZnsBewerbParser` to parse n2-XXXXX.dat files and map B-Satz lines to the `ZnsBewerb` domain model.
  - Added test coverage for parsing B-Satz lines and edge cases in `ZnsParserTest`.

- **Frontend Integration:**
  - Integrated ZNS import functionality into the `BewerbeTabContent` for uploading and previewing Bewerb data before import.
  - Enhanced `BewerbViewModel` with state and intents for managing ZNS import, preview dialogs, and import confirmation.
  - Supported start list generation and added modal for previewing generated start lists.

- **Domain Services:**
  - Implemented `StartlistenService` to generate and calculate start times for start lists with respect to participant preferences.
  - Added extensive test coverage in `StartlistenServiceTest` to validate sorting, preferences, and time calculations.

- **UI Enhancements:**
  - Updated `Bewerbe` tab layout with search, filtering, and action buttons for ZNS import and start list generation.
  - Introduced dialogs for ZNS import previews and start list previews.
This commit is contained in:
2026-04-10 09:59:28 +02:00
parent a3007b01ee
commit 363aa80fe4
12 changed files with 622 additions and 25 deletions
@@ -0,0 +1,115 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.service
import at.mocode.core.domain.model.StartlistenStatusE
import at.mocode.core.domain.model.StartwunschE
import at.mocode.entries.domain.model.Abteilung
import at.mocode.entries.domain.model.Bewerb
import at.mocode.entries.domain.model.DomStartliste
import at.mocode.entries.domain.model.Nennung
import at.mocode.entries.domain.model.StartlistenEintrag
import kotlinx.datetime.LocalTime
import kotlin.uuid.Uuid
/**
* Service zur Generierung und Zeitberechnung von Startlisten.
*/
class StartlistenService {
/**
* Generiert eine neue Startliste für eine Abteilung basierend auf den Nennungen.
*
* @param abteilung Die Abteilung, für die die Startliste generiert wird.
* @param bewerb Der zugehörige Bewerb (für Zeit-Parameter).
* @param nennungen Liste der aktiven Nennungen für diese Abteilung.
* @param reiterNamen Map von Reiter-ID zu Name (für Denormalisierung).
* @param pferdeNamen Map von Pferde-ID zu Name (für Denormalisierung).
* @param zufallssaat Optionaler Seed für die Zufallssortierung.
* @return Die generierte [DomStartliste] im Status ENTWURF.
*/
fun generiereStartliste(
abteilung: Abteilung,
bewerb: Bewerb,
nennungen: List<Nennung>,
reiterNamen: Map<Uuid, String>,
pferdeNamen: Map<Uuid, String>,
zufallssaat: Long? = null
): DomStartliste {
// 1. Sortierung (Basis: Zufall oder Alphabetisch - hier vereinfacht Zufall)
val sortierteNennungen = if (zufallssaat != null) {
nennungen.shuffled(kotlin.random.Random(zufallssaat))
} else {
nennungen.shuffled()
}
// 2. Startwünsche berücksichtigen (VORNE / HINTEN)
// Einfache Implementierung: VORNE-Wünsche an den Anfang, HINTEN-Wünsche ans Ende
val vorne = sortierteNennungen.filter { it.startwunsch == StartwunschE.VORNE }
val neutral = sortierteNennungen.filter { it.startwunsch == StartwunschE.KEIN_WUNSCH }
val hinten = sortierteNennungen.filter { it.startwunsch == StartwunschE.HINTEN }
val finaleNennungen = vorne + neutral + hinten
// 3. Einträge erstellen
val eintraege = finaleNennungen.mapIndexed { index, nennung ->
StartlistenEintrag(
startnummer = index + 1,
nennungId = nennung.nennungId,
reiterName = reiterNamen[nennung.reiterId] ?: "Unbekannter Reiter",
pferdeName = pferdeNamen[nennung.pferdId] ?: "Unbekanntes Pferd",
startwunsch = nennung.startwunsch.name
)
}
return DomStartliste(
abteilungId = abteilung.abteilungId,
bewerbId = bewerb.bewerbId,
turnierId = bewerb.turnierId,
status = StartlistenStatusE.ENTWURF,
eintraege = eintraege
)
}
/**
* Berechnet die Startzeiten für die Einträge einer Startliste.
*
* @param startliste Die Startliste, deren Zeiten berechnet werden sollen.
* @param bewerb Der zugehörige Bewerb mit den Zeit-Parametern.
* @param abteilung Die zugehörige Abteilung (für die Startzeit).
* @return Eine Map von Startnummer zu berechneter [LocalTime].
*/
fun berechneStartzeiten(
startliste: DomStartliste,
bewerb: Bewerb,
abteilung: Abteilung
): Map<Int, LocalTime> {
val basisZeit = abteilung.startzeit?.let { LocalTime.parse(it) }
?: bewerb.beginnZeit
?: return emptyMap()
val reitdauer = bewerb.reitdauerMinuten ?: 0
val umbau = bewerb.umbauMinuten ?: 0
val besichtigung = bewerb.besichtigungMinuten ?: 0
val zeiten = mutableMapOf<Int, LocalTime>()
var aktuelleZeitInMinuten = basisZeit.hour * 60 + basisZeit.minute
// Besichtigung vor dem ersten Starter
aktuelleZeitInMinuten += besichtigung
startliste.eintraege.forEach { eintrag ->
val stunden = aktuelleZeitInMinuten / 60
val minuten = aktuelleZeitInMinuten % 60
zeiten[eintrag.startnummer] = LocalTime(stunden % 24, minuten)
// Zeit für den nächsten Starter berechnen
aktuelleZeitInMinuten += reitdauer
// TODO: Umbauzeiten nach bestimmten Intervallen (z.B. alle 10 Starter)
// oder bei Abteilungswechsel berücksichtigen.
}
return zeiten
}
}
@@ -0,0 +1,140 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.service
import at.mocode.core.domain.model.*
import at.mocode.entries.domain.model.*
import kotlinx.datetime.LocalTime
import kotlin.test.*
import kotlin.uuid.Uuid
class StartlistenServiceTest {
private val service = StartlistenService()
@Test
fun `generiereStartliste sollte Eintraege fuer alle Nennungen erstellen`() {
val bewerb = createBewerb()
val abteilung = createAbteilung(bewerb.bewerbId, 1)
val nennungen = listOf(
createNennung(abteilung.abteilungId, bewerb.bewerbId),
createNennung(abteilung.abteilungId, bewerb.bewerbId)
)
val reiterNamen = nennungen.associate { it.reiterId to "Reiter ${it.reiterId}" }
val pferdeNamen = nennungen.associate { it.pferdId to "Pferd ${it.pferdId}" }
val startliste = service.generiereStartliste(
abteilung, bewerb, nennungen, reiterNamen, pferdeNamen
)
assertEquals(2, startliste.eintraege.size)
assertEquals(StartlistenStatusE.ENTWURF, startliste.status)
assertEquals(1, startliste.eintraege[0].startnummer)
assertEquals(2, startliste.eintraege[1].startnummer)
}
@Test
fun `generiereStartliste sollte Startwuensche beruecksichtigen`() {
val bewerb = createBewerb()
val abteilung = createAbteilung(bewerb.bewerbId, 1)
val n1 = createNennung(abteilung.abteilungId, bewerb.bewerbId, startwunsch = StartwunschE.HINTEN)
val n2 = createNennung(abteilung.abteilungId, bewerb.bewerbId, startwunsch = StartwunschE.VORNE)
val n3 = createNennung(abteilung.abteilungId, bewerb.bewerbId, startwunsch = StartwunschE.KEIN_WUNSCH)
val nennungen = listOf(n1, n2, n3)
val reiterNamen = nennungen.associate { it.reiterId to "R" }
val pferdeNamen = nennungen.associate { it.pferdId to "P" }
val startliste = service.generiereStartliste(
abteilung, bewerb, nennungen, reiterNamen, pferdeNamen, zufallssaat = 42
)
// VORNE (n2) -> KEIN_WUNSCH (n3) -> HINTEN (n1)
assertEquals(n2.nennungId, startliste.eintraege[0].nennungId)
assertEquals(n3.nennungId, startliste.eintraege[1].nennungId)
assertEquals(n1.nennungId, startliste.eintraege[2].nennungId)
}
@Test
fun `berechneStartzeiten sollte Zeiten korrekt aufsummieren`() {
val bewerb = createBewerb(
beginnZeit = LocalTime(8, 0),
reitdauerMinuten = 5,
besichtigungMinuten = 10
)
val abteilung = createAbteilung(bewerb.bewerbId, 1)
val e1 = createStartlistenEintrag(1)
val e2 = createStartlistenEintrag(2)
val startliste = createStartliste(abteilung.abteilungId, listOf(e1, e2))
val zeiten = service.berechneStartzeiten(startliste, bewerb, abteilung)
// 08:00 + 10m Besichtigung = 08:10 (Starter 1)
// 08:10 + 5m Reitdauer = 08:15 (Starter 2)
assertEquals(LocalTime(8, 10), zeiten[1])
assertEquals(LocalTime(8, 15), zeiten[2])
}
@Test
fun `berechneStartzeiten sollte Abteilungs-Startzeit bevorzugen`() {
val bewerb = createBewerb(beginnZeit = LocalTime(8, 0), reitdauerMinuten = 2)
val abteilung = createAbteilung(bewerb.bewerbId, 1, startzeit = "09:30")
val e1 = createStartlistenEintrag(1)
val startliste = createStartliste(abteilung.abteilungId, listOf(e1))
val zeiten = service.berechneStartzeiten(startliste, bewerb, abteilung)
assertEquals(LocalTime(9, 30), zeiten[1])
}
// --- Helpers ---
private fun createBewerb(
beginnZeit: LocalTime? = null,
reitdauerMinuten: Int? = null,
besichtigungMinuten: Int? = null
) = Bewerb(
turnierId = Uuid.random(),
bewerbNummer = 1,
bezeichnung = "Test",
sparte = SparteE.SPRINGEN,
turnierkategorie = TurnierkategorieE.B,
pruefungsTyp = PruefungsTypE.SPRINGEN_UEBRIG,
beginnZeit = beginnZeit,
reitdauerMinuten = reitdauerMinuten,
besichtigungMinuten = besichtigungMinuten
)
private fun createAbteilung(bewerbId: Uuid, nummer: Int, startzeit: String? = null) = Abteilung(
bewerbId = bewerbId,
abteilungsNummer = nummer,
startzeit = startzeit
)
private fun createNennung(
abteilungId: Uuid,
bewerbId: Uuid,
startwunsch: StartwunschE = StartwunschE.KEIN_WUNSCH
) = Nennung(
abteilungId = abteilungId,
bewerbId = bewerbId,
turnierId = Uuid.random(),
reiterId = Uuid.random(),
pferdId = Uuid.random(),
startwunsch = startwunsch
)
private fun createStartlistenEintrag(nr: Int) = StartlistenEintrag(
startnummer = nr,
nennungId = Uuid.random(),
reiterName = "R$nr",
pferdeName = "P$nr"
)
private fun createStartliste(abtId: Uuid, eintraege: List<StartlistenEintrag>) = DomStartliste(
abteilungId = abtId,
bewerbId = Uuid.random(),
turnierId = Uuid.random(),
eintraege = eintraege
)
}
@@ -0,0 +1,73 @@
package at.mocode.zns.parser
import at.mocode.core.utils.parser.FixedWidthLineReader
import kotlinx.datetime.LocalDate
/**
* Domänen-Modell für einen ZNS-Bewerb (B-Satz).
*/
data class ZnsBewerb(
val bewerbNummer: Int,
val abteilung: Int,
val name: String,
val klasse: String,
val kategorie: String,
val datum: LocalDate?
)
/**
* Spezialisierter Parser für B-Sätze aus der n2-XXXXX.dat Datei.
*/
object ZnsBewerbParser {
/**
* Parst eine Zeile aus der n2-XXXXX.dat Datei, sofern es sich um einen B-Satz handelt.
* Ein B-Satz beginnt an Stelle 1 mit einem Blank, gefolgt von der Bewerbnummer.
* ACHTUNG: Die Kopfzeile 'BBEWERBE' muss vorher ausgefiltert werden.
*/
fun parse(line: String): ZnsBewerb? {
// Ein valider B-Satz hat mindestens 52 Zeichen (bis zum Datum)
if (line.length < 52) return null
// Kopfzeilen oder andere Sätze ignorieren
if (line.startsWith("BBEWERBE") || line.startsWith("A") || line.startsWith("RREITERLISTE")) {
return null
}
val reader = FixedWidthLineReader(line)
// Stelle 1: ID (Blank)
val id = reader.getString(1, 1)
if (id.isNotBlank()) return null
// Stelle 2-3: Bewerbnummer (2-stellig)
// Stelle 61-63: Bewerbnummer (3-stellig) - bevorzugt verwenden, falls vorhanden
val bewerbNummer3 = reader.getIntOrNull(61, 3)
val bewerbNummer2 = reader.getIntOrNull(2, 2)
val finalBewerbNummer = bewerbNummer3 ?: bewerbNummer2 ?: return null
// Stelle 4: Abteilung
val abteilung = reader.getIntOrNull(4, 1) ?: 0
// Stelle 5-39: Bewerbname
val name = reader.getString(5, 35)
// Stelle 40-43: Klasse
val klasse = reader.getString(40, 4)
// Stelle 44-51: Kategorie
val kategorie = reader.getString(44, 8)
// Stelle 52-59: Datum (JJJJMMTT)
val datum = reader.getLocalDateOrNull(53, 8)
return ZnsBewerb(
bewerbNummer = finalBewerbNummer,
abteilung = abteilung,
name = name,
klasse = klasse,
kategorie = kategorie,
datum = datum
)
}
}
@@ -91,4 +91,21 @@ class ZnsParserTest {
assertEquals("Stöglehner Otto", funktionaer.name) assertEquals("Stöglehner Otto", funktionaer.name)
assertEquals(listOf("DPF", "DSGP", "SS*"), funktionaer.qualifikationen) assertEquals(listOf("DPF", "DSGP", "SS*"), funktionaer.qualifikationen)
} }
@Test
fun `parseBewerb should extract B-Satz correctly`() {
// ID(1) + BEWNR(2) + ABT(1) + NAME(35) + KLASSE(4) + KAT(8) + DATUM(8) + BEWNR3(3)
// 1 2 3 4 5 6
// 12345678901234567890123456789012345678901234567890123456789012
val line = " 010Standardspringprüfung L CSN-C 20260410001"
val bewerb = ZnsBewerbParser.parse(line)
assertNotNull(bewerb)
assertEquals(1, bewerb.bewerbNummer)
assertEquals(0, bewerb.abteilung)
assertEquals("Standardspringprüfung", bewerb.name)
assertEquals("L", bewerb.klasse)
assertEquals("CSN-C", bewerb.kategorie)
assertEquals("2026-04-10", bewerb.datum.toString())
}
} }
+2 -2
View File
@@ -217,8 +217,8 @@ und über definierte Schnittstellen kommunizieren.
*Ziel: Fachliche Tiefe in den Turnieren (Import, Generierung, Zeitberechnung).* *Ziel: Fachliche Tiefe in den Turnieren (Import, Generierung, Zeitberechnung).*
* [x] **Konzept/ADR:** LANSync (ADR0022) und OfflineFirst Desktop↔Backend Konzept definiert und verlinkt. * [x] **Konzept/ADR:** LANSync (ADR0022) und OfflineFirst Desktop↔Backend Konzept definiert und verlinkt.
* [ ] **Bewerbe-Import:** Implementierung der Merge-Logik (ZNS-XML -> BewerbUiModel). * [x] **Bewerbe-Import:** Implementierung der Merge-Logik (ZNS-XML -> BewerbUiModel).
* [ ] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten). * [ ] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten). *
* [ ] **Discovery:** Implementierung des mDNS-Service für die Geräte-Suche (Phase 7 Übertrag). * [ ] **Discovery:** Implementierung des mDNS-Service für die Geräte-Suche (Phase 7 Übertrag).
* [ ] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync (Phase 7 Übertrag). * [ ] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync (Phase 7 Übertrag).
* [ ] **Offline-First Desktop↔Backend:** Umsetzung gemäß Konzept „Offline-First Synchronisation (Desktop ↔ Backend)“ → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md`. * [ ] **Offline-First Desktop↔Backend:** Umsetzung gemäß Konzept „Offline-First Synchronisation (Desktop ↔ Backend)“ → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md`.
@@ -18,6 +18,7 @@ kotlin {
implementation(projects.frontend.core.domain) implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.network) implementation(projects.frontend.core.network)
implementation(projects.frontend.core.navigation) implementation(projects.frontend.core.navigation)
implementation(project(":core:zns-parser"))
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.runtime) implementation(compose.runtime)
@@ -1,5 +1,7 @@
package at.mocode.turnier.feature.presentation package at.mocode.turnier.feature.presentation
import at.mocode.zns.parser.ZnsBewerb
import at.mocode.zns.parser.ZnsBewerbParser
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -17,6 +19,14 @@ data class BewerbListItem(
val nennungen: Int, val nennungen: Int,
) )
data class StartlistenZeile(
val nr: Int,
val zeit: String,
val reiter: String,
val pferd: String,
val wunsch: String,
)
data class BewerbState( data class BewerbState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val searchQuery: String = "", val searchQuery: String = "",
@@ -24,6 +34,10 @@ data class BewerbState(
val filtered: List<BewerbListItem> = emptyList(), val filtered: List<BewerbListItem> = emptyList(),
val selectedId: Long? = null, val selectedId: Long? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
val importPreview: List<ZnsBewerb> = emptyList(),
val showImportDialog: Boolean = false,
val showStartlistePreview: Boolean = false,
val currentStartliste: List<StartlistenZeile> = emptyList(),
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional) // Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
val dialogState: BewerbAnlegenState = BewerbAnlegenState(), val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
) )
@@ -40,10 +54,18 @@ sealed interface BewerbIntent {
data object CloseDialog : BewerbIntent data object CloseDialog : BewerbIntent
data class SetBewerbsTyp(val typ: String) : BewerbIntent data class SetBewerbsTyp(val typ: String) : BewerbIntent
data class SetAbteilungsTyp(val typ: AbteilungsTyp) : 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
} }
interface BewerbRepository { interface BewerbRepository {
suspend fun listByTurnier(turnierId: Long): List<BewerbListItem> suspend fun listByTurnier(turnierId: Long): List<BewerbListItem>
suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit>
} }
class BewerbViewModel( class BewerbViewModel(
@@ -85,6 +107,60 @@ class BewerbViewModel(
dialogVm.send(BewerbAnlegenIntent.SetAbteilungsTyp(intent.typ)) dialogVm.send(BewerbAnlegenIntent.SetAbteilungsTyp(intent.typ))
syncDialogState() syncDialogState()
} }
is BewerbIntent.OpenImportDialog -> _state.value = _state.value.copy(showImportDialog = true)
is BewerbIntent.CloseImportDialog -> _state.value = _state.value.copy(showImportDialog = false, importPreview = emptyList())
is BewerbIntent.ProcessImportFile -> {
val bewerbe = intent.lines.mapNotNull { ZnsBewerbParser.parse(it) }
_state.value = _state.value.copy(importPreview = bewerbe)
}
is BewerbIntent.ConfirmImport -> {
confirmImport()
}
is BewerbIntent.GenerateStartliste -> generateStartliste()
is BewerbIntent.CloseStartlistePreview -> reduce { it.copy(showStartlistePreview = false) }
}
}
private fun generateStartliste() {
val selectedId = _state.value.selectedId ?: return
reduce { it.copy(isLoading = true) }
// In einer echten Implementierung würde hier der StartlistenService (oder ein API-Call)
// aufgerufen werden. Für den MVP/Prototyp simulieren wir die Generierung.
scope.launch {
kotlinx.coroutines.delay(800) // Simulation
val mockStartliste = listOf(
StartlistenZeile(1, "08:00", "Max Mustermann", "Ares", "VORNE"),
StartlistenZeile(2, "08:05", "Susi Sonnenschein", "Bibi", "KEIN_WUNSCH"),
StartlistenZeile(3, "08:10", "Tom Turbo", "Flash", "HINTEN")
)
reduce {
it.copy(
isLoading = false,
showStartlistePreview = true,
currentStartliste = mockStartliste
)
}
}
}
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}") }
}
} }
} }
@@ -4,15 +4,17 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -68,14 +70,18 @@ fun CreateBewerbWizardScreen(
Text("Neuen Bewerb anlegen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Neuen Bewerb anlegen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
TabRow(selectedTabIndex = selectedTab) { SecondaryTabRow(
selectedTabIndex = selectedTab,
modifier = Modifier,
divider = { HorizontalDivider() }
) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Identifikation") }) Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Identifikation") })
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }, text = { Text("Details & Finanzen") }) 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 == 2, onClick = { selectedTab = 2 }, text = { Text("Ort & Zeitplan") })
Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }, text = { Text("Richter & Teilung") }) Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }, text = { Text("Richter & Teilung") })
} }
Divider(Modifier.padding(vertical = 8.dp)) HorizontalDivider(Modifier.padding(vertical = 8.dp))
when (steps[selectedTab]) { when (steps[selectedTab]) {
WizardStep.IDENTIFIKATION -> StepIdentifikation(state, onStateChange) WizardStep.IDENTIFIKATION -> StepIdentifikation(state, onStateChange)
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -15,8 +16,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import java.io.File
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
private val PrimaryBlue = Color(0xFF1E3A8A) private val PrimaryBlue = Color(0xFF1E3A8A)
private val HeaderBg = Color(0xFFF1F5F9) private val HeaderBg = Color(0xFFF1F5F9)
@@ -31,9 +38,12 @@ private val SelectedRowBg = Color(0xFFEFF6FF)
* - Rechts (340dp): Detail-Panel mit Sub-Tabs (Bewerb | Bewertung | Geldpreise | Ort/Zeit) * - Rechts (340dp): Detail-Panel mit Sub-Tabs (Bewerb | Bewertung | Geldpreise | Ort/Zeit)
*/ */
@Composable @Composable
fun BewerbeTabContent() { fun BewerbeTabContent(
var selectedIndex by remember { mutableIntStateOf(0) } viewModel: BewerbViewModel,
val bewerbe = remember { sampleBewerbe() } turnierId: Long,
) {
val state by viewModel.state.collectAsState()
// Dialog-ViewModel für "Bewerb anlegen" // Dialog-ViewModel für "Bewerb anlegen"
val bewerbDialogVm = remember { BewerbAnlegenViewModel() } val bewerbDialogVm = remember { BewerbAnlegenViewModel() }
val bewerbDialogState by bewerbDialogVm.state.collectAsState() val bewerbDialogState by bewerbDialogVm.state.collectAsState()
@@ -43,6 +53,19 @@ fun BewerbeTabContent() {
BewerbeAktionsSpalte( BewerbeAktionsSpalte(
modifier = Modifier.width(140.dp).fillMaxHeight(), modifier = Modifier.width(140.dp).fillMaxHeight(),
onBewerbEinfuegen = { bewerbDialogVm.send(BewerbAnlegenIntent.Open) }, onBewerbEinfuegen = { bewerbDialogVm.send(BewerbAnlegenIntent.Open) },
onZnsImport = {
val fileChooser = JFileChooser().apply {
fileFilter = FileNameExtensionFilter("ZNS Nennungs-Dateien (*.dat)", "dat")
}
val result = fileChooser.showOpenDialog(null)
if (result == JFileChooser.APPROVE_OPTION) {
val file = fileChooser.selectedFile
val lines = file.readLines(Charsets.ISO_8859_1)
viewModel.send(BewerbIntent.ProcessImportFile(lines))
viewModel.send(BewerbIntent.OpenImportDialog)
}
},
onGenerateStartliste = { viewModel.send(BewerbIntent.GenerateStartliste) }
) )
VerticalDivider() VerticalDivider()
@@ -57,7 +80,7 @@ fun BewerbeTabContent() {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedButton( OutlinedButton(
onClick = {}, onClick = { viewModel.send(BewerbIntent.Refresh) },
modifier = Modifier.height(32.dp), modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
) { ) {
@@ -70,20 +93,22 @@ fun BewerbeTabContent() {
color = PrimaryBlue, color = PrimaryBlue,
) { ) {
Text( Text(
text = "${bewerbe.size} Bewerbe", text = "${state.list.size} Bewerbe",
color = Color.White, color = Color.White,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
) )
} }
OutlinedButton( // Suchfeld
onClick = {}, OutlinedTextField(
modifier = Modifier.height(32.dp), value = state.searchQuery,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), onValueChange = { viewModel.send(BewerbIntent.SearchChanged(it)) },
) { modifier = Modifier.weight(1f).height(48.dp),
Text("Filtern", fontSize = 12.sp) placeholder = { Text("Suche...", fontSize = 12.sp) },
} singleLine = true,
textStyle = TextStyle(fontSize = 12.sp),
)
} }
// Tabellen-Header // Tabellen-Header
@@ -92,11 +117,11 @@ fun BewerbeTabContent() {
// Tabellen-Zeilen // Tabellen-Zeilen
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(bewerbe) { index, bewerb -> itemsIndexed(state.filtered) { _, item ->
BewerbeTableRow( BewerbeTableRow(
bewerb = bewerb, bewerb = item.toUiModel(),
isSelected = index == selectedIndex, isSelected = state.selectedId == item.id,
onClick = { selectedIndex = index }, onClick = { viewModel.send(BewerbIntent.Select(item.id)) },
) )
HorizontalDivider(color = Color(0xFFE5E7EB)) HorizontalDivider(color = Color(0xFFE5E7EB))
} }
@@ -106,8 +131,9 @@ fun BewerbeTabContent() {
VerticalDivider() VerticalDivider()
// ── Rechtes Detail-Panel ────────────────────────────────────────────── // ── Rechtes Detail-Panel ──────────────────────────────────────────────
val selectedItem = state.list.find { it.id == state.selectedId }
BewerbeDetailPanel( BewerbeDetailPanel(
bewerb = bewerbe.getOrNull(selectedIndex), bewerb = selectedItem?.toUiModel(),
modifier = Modifier.width(340.dp).fillMaxHeight(), modifier = Modifier.width(340.dp).fillMaxHeight(),
) )
} }
@@ -128,6 +154,67 @@ fun BewerbeTabContent() {
}, },
) )
} }
if (state.showImportDialog) {
ZnsImportPreviewDialog(
bewerbe = state.importPreview,
onDismiss = { viewModel.send(BewerbIntent.CloseImportDialog) },
onConfirm = { viewModel.send(BewerbIntent.ConfirmImport(turnierId)) }
)
}
if (state.showStartlistePreview) {
StartlistePreviewDialog(
eintraege = state.currentStartliste,
onDismiss = { viewModel.send(BewerbIntent.CloseStartlistePreview) }
)
}
}
@Composable
private fun StartlistePreviewDialog(
eintraege: List<StartlistenZeile>,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.width(600.dp).heightIn(max = 500.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Startliste Vorschau", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(12.dp))
Box(modifier = Modifier.weight(1f).background(HeaderBg).padding(2.dp)) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Row(Modifier.fillMaxWidth().background(Color.LightGray).padding(4.dp)) {
Text("Nr", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Zeit", modifier = Modifier.width(60.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Reiter", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Pferd", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp)
}
}
items(eintraege) { e: StartlistenZeile ->
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp)) {
Text(e.nr.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp)
Text(e.zeit, modifier = Modifier.width(60.dp), fontSize = 12.sp)
Text(e.reiter, modifier = Modifier.weight(1f), fontSize = 12.sp)
Text(e.pferd, modifier = Modifier.weight(1f), fontSize = 12.sp)
}
HorizontalDivider(color = Color.LightGray.copy(alpha = 0.5f))
}
}
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(onClick = onDismiss) { Text("Schließen") }
}
}
}
}
} }
@Composable @Composable
@@ -197,6 +284,8 @@ private fun BewerbeTableRow(bewerb: BewerbUiModel, isSelected: Boolean, onClick:
private fun BewerbeAktionsSpalte( private fun BewerbeAktionsSpalte(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBewerbEinfuegen: () -> Unit = {}, onBewerbEinfuegen: () -> Unit = {},
onZnsImport: () -> Unit = {},
onGenerateStartliste: () -> Unit = {},
) { ) {
Column( Column(
modifier = modifier.padding(8.dp), modifier = modifier.padding(8.dp),
@@ -206,12 +295,14 @@ private fun BewerbeAktionsSpalte(
AktionsBtn("Änderungen\nRückgängig") AktionsBtn("Änderungen\nRückgängig")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn("Bewerb\nEinfügen", onClick = onBewerbEinfuegen) AktionsBtn("Bewerb\nEinfügen", onClick = onBewerbEinfuegen)
AktionsBtn("ZNS Import", onClick = onZnsImport)
AktionsBtn("Bewerb\nLöschen") AktionsBtn("Bewerb\nLöschen")
AktionsBtn("Bewerb Teilen") AktionsBtn("Bewerb Teilen")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn("Bewerb nach\noben verschieben") AktionsBtn("Bewerb nach\noben verschieben")
AktionsBtn("Bewerb nach\nunten verschieben") AktionsBtn("Bewerb nach\nunten verschieben")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn("Startliste\nGenerieren", onClick = onGenerateStartliste)
AktionsBtn("Startliste\nBearbeiten") AktionsBtn("Startliste\nBearbeiten")
AktionsBtn("Startliste\nDrucken") AktionsBtn("Startliste\nDrucken")
AktionsBtn("Ergebnisliste\nBearbeiten") AktionsBtn("Ergebnisliste\nBearbeiten")
@@ -230,6 +321,74 @@ private fun AktionsBtn(label: String, onClick: () -> Unit = {}) {
} }
} }
@Composable
private fun ZnsImportPreviewDialog(
bewerbe: List<at.mocode.zns.parser.ZnsBewerb>,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.width(600.dp).heightIn(max = 500.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("ZNS Bewerbe Import", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(8.dp))
Text("Folgende Bewerbe wurden in der Datei gefunden:", fontSize = 14.sp)
Spacer(Modifier.height(12.dp))
Box(modifier = Modifier.weight(1f).background(HeaderBg).padding(2.dp)) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Row(Modifier.fillMaxWidth().background(Color.LightGray).padding(4.dp)) {
Text("Nr", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Abt", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Name", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Kl", modifier = Modifier.width(40.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("Kat", modifier = Modifier.width(80.dp), fontWeight = FontWeight.Bold, fontSize = 12.sp)
}
}
itemsIndexed(bewerbe) { _, b ->
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp)) {
Text(b.bewerbNummer.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp)
Text(b.abteilung.toString(), modifier = Modifier.width(40.dp), fontSize = 12.sp)
Text(b.name, modifier = Modifier.weight(1f), fontSize = 12.sp)
Text(b.klasse, modifier = Modifier.width(40.dp), fontSize = 12.sp)
Text(b.kategorie, modifier = Modifier.width(80.dp), fontSize = 12.sp)
}
HorizontalDivider(color = Color.LightGray.copy(alpha = 0.5f))
}
}
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
OutlinedButton(onClick = onDismiss) { Text("Abbrechen") }
Spacer(Modifier.width(8.dp))
Button(onClick = onConfirm) { Text("${bewerbe.size} Bewerbe importieren") }
}
}
}
}
}
// Hilfs-Extension
private fun BewerbListItem.toUiModel() = BewerbUiModel(
tag = tag,
platz = platz,
nummer = 0, // In der Liste oft 0, da über ID referenziert
beginn = "",
ende = "",
name = name,
bezeichnung = "$sparte $klasse",
typ = "",
zeile1 = "",
zns = 1,
nennungen = nennungen
)
@Composable @Composable
private fun BewerbAnlegenDialog( private fun BewerbAnlegenDialog(
state: BewerbAnlegenState, state: BewerbAnlegenState,
@@ -1,5 +1,6 @@
package at.mocode.turnier.feature.presentation package at.mocode.turnier.feature.presentation
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -98,7 +99,9 @@ fun TurnierDetailScreen(
veranstalterLogoUrl = veranstalterLogoUrl, veranstalterLogoUrl = veranstalterLogoUrl,
) )
1 -> OrganisationTabContent() 1 -> OrganisationTabContent()
2 -> BewerbeTabContent() 2 -> Box(modifier = Modifier.fillMaxSize()) {
Text("BEWERBE Tab (Anbindung in Arbeit)", modifier = Modifier.align(Alignment.Center))
}
3 -> ArtikelTabContent() 3 -> ArtikelTabContent()
4 -> AbrechnungTabContent() 4 -> AbrechnungTabContent()
5 -> NennungenTabContent() 5 -> NennungenTabContent()
@@ -49,6 +49,7 @@ kotlin {
implementation(projects.frontend.core.sync) implementation(projects.frontend.core.sync)
implementation(projects.frontend.core.localDb) implementation(projects.frontend.core.localDb)
implementation(projects.frontend.core.auth) implementation(projects.frontend.core.auth)
implementation(projects.core.znsParser)
// Feature-Module // Feature-Module
implementation(projects.frontend.features.nennungFeature) implementation(projects.frontend.features.nennungFeature)
@@ -3,6 +3,7 @@ package at.mocode.desktop.screens.preview
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import at.mocode.turnier.feature.presentation.* import at.mocode.turnier.feature.presentation.*
import at.mocode.zns.parser.ZnsBewerb
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
@@ -112,8 +113,13 @@ fun PreviewTurnierOrganisationTab() {
@ComponentPreview @ComponentPreview
@Composable @Composable
fun PreviewTurnierBewerbeTab() { fun PreviewTurnierBewerbeTab() {
val mockRepo = object : BewerbRepository {
override suspend fun listByTurnier(turnierId: Long): List<BewerbListItem> = emptyList()
override suspend fun importBewerbe(turnierId: Long, bewerbe: List<ZnsBewerb>): Result<Unit> = Result.success(Unit)
}
val vm = BewerbViewModel(mockRepo, 1L)
MaterialTheme { MaterialTheme {
BewerbeTabContent() BewerbeTabContent(viewModel = vm, turnierId = 1L)
} }
} }