diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/StartlistenService.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/StartlistenService.kt new file mode 100644 index 00000000..f846ef47 --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/StartlistenService.kt @@ -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, + reiterNamen: Map, + pferdeNamen: Map, + 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 { + 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() + 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 + } +} diff --git a/backend/services/entries/entries-domain/src/test/kotlin/at/mocode/entries/domain/service/StartlistenServiceTest.kt b/backend/services/entries/entries-domain/src/test/kotlin/at/mocode/entries/domain/service/StartlistenServiceTest.kt new file mode 100644 index 00000000..f35dd857 --- /dev/null +++ b/backend/services/entries/entries-domain/src/test/kotlin/at/mocode/entries/domain/service/StartlistenServiceTest.kt @@ -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) = DomStartliste( + abteilungId = abtId, + bewerbId = Uuid.random(), + turnierId = Uuid.random(), + eintraege = eintraege + ) +} diff --git a/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsBewerbParser.kt b/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsBewerbParser.kt new file mode 100644 index 00000000..e73a08eb --- /dev/null +++ b/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsBewerbParser.kt @@ -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 + ) + } +} diff --git a/core/zns-parser/src/commonTest/kotlin/at/mocode/zns/parser/ZnsParserTest.kt b/core/zns-parser/src/commonTest/kotlin/at/mocode/zns/parser/ZnsParserTest.kt index 4161c01b..b0b7ebe2 100644 --- a/core/zns-parser/src/commonTest/kotlin/at/mocode/zns/parser/ZnsParserTest.kt +++ b/core/zns-parser/src/commonTest/kotlin/at/mocode/zns/parser/ZnsParserTest.kt @@ -91,4 +91,21 @@ class ZnsParserTest { assertEquals("Stöglehner Otto", funktionaer.name) 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()) + } } diff --git a/docs/01_Architecture/MASTER_ROADMAP.md b/docs/01_Architecture/MASTER_ROADMAP.md index 6f0a935d..18977713 100644 --- a/docs/01_Architecture/MASTER_ROADMAP.md +++ b/docs/01_Architecture/MASTER_ROADMAP.md @@ -217,8 +217,8 @@ und über definierte Schnittstellen kommunizieren. *Ziel: Fachliche Tiefe in den Turnieren (Import, Generierung, Zeitberechnung).* * [x] **Konzept/ADR:** LAN‑Sync (ADR‑0022) und Offline‑First Desktop↔Backend Konzept definiert und verlinkt. -* [ ] **Bewerbe-Import:** Implementierung der Merge-Logik (ZNS-XML -> BewerbUiModel). -* [ ] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten). +* [x] **Bewerbe-Import:** Implementierung der Merge-Logik (ZNS-XML -> BewerbUiModel). ✓ +* [ ] **Startlisten-Automatisierung:** Generierung und Zeitberechnung (Pausen, Umbauzeiten). * * [ ] **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). * [ ] **Offline-First Desktop↔Backend:** Umsetzung gemäß Konzept „Offline-First Synchronisation (Desktop ↔ Backend)“ → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md`. diff --git a/frontend/features/turnier-feature/build.gradle.kts b/frontend/features/turnier-feature/build.gradle.kts index 8a9a2520..1ae715ed 100644 --- a/frontend/features/turnier-feature/build.gradle.kts +++ b/frontend/features/turnier-feature/build.gradle.kts @@ -18,6 +18,7 @@ kotlin { implementation(projects.frontend.core.domain) implementation(projects.frontend.core.network) implementation(projects.frontend.core.navigation) + implementation(project(":core:zns-parser")) implementation(compose.desktop.currentOs) implementation(compose.foundation) implementation(compose.runtime) diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt index c3bc0eed..83b02e50 100644 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/BewerbViewModel.kt @@ -1,5 +1,7 @@ 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.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -17,6 +19,14 @@ data class BewerbListItem( val nennungen: Int, ) +data class StartlistenZeile( + val nr: Int, + val zeit: String, + val reiter: String, + val pferd: String, + val wunsch: String, +) + data class BewerbState( val isLoading: Boolean = false, val searchQuery: String = "", @@ -24,6 +34,10 @@ data class BewerbState( val filtered: List = emptyList(), val selectedId: Long? = null, val errorMessage: String? = null, + val importPreview: List = emptyList(), + val showImportDialog: Boolean = false, + val showStartlistePreview: Boolean = false, + val currentStartliste: List = emptyList(), // Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional) val dialogState: BewerbAnlegenState = BewerbAnlegenState(), ) @@ -40,10 +54,18 @@ sealed interface 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) : BewerbIntent + data class ConfirmImport(val turnierId: Long) : BewerbIntent + data object GenerateStartliste : BewerbIntent + data object CloseStartlistePreview : BewerbIntent } interface BewerbRepository { suspend fun listByTurnier(turnierId: Long): List + suspend fun importBewerbe(turnierId: Long, bewerbe: List): Result } class BewerbViewModel( @@ -85,6 +107,60 @@ class BewerbViewModel( 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()) + 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}") } + } } } diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/CreateBewerbWizardScreen.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/CreateBewerbWizardScreen.kt index 40412432..c0e408ef 100644 --- a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/CreateBewerbWizardScreen.kt +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/CreateBewerbWizardScreen.kt @@ -4,15 +4,17 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Checkbox -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -68,14 +70,18 @@ fun CreateBewerbWizardScreen( Text("Neuen Bewerb anlegen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) 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 == 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") }) } - Divider(Modifier.padding(vertical = 8.dp)) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) when (steps[selectedTab]) { WizardStep.IDENTIFIKATION -> StepIdentifikation(state, onStateChange) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt index e6130550..4b86eac5 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierBewerbeTab.kt @@ -4,6 +4,7 @@ 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.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -15,8 +16,14 @@ 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.text.TextStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp 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 HeaderBg = Color(0xFFF1F5F9) @@ -31,9 +38,12 @@ private val SelectedRowBg = Color(0xFFEFF6FF) * - Rechts (340dp): Detail-Panel mit Sub-Tabs (Bewerb | Bewertung | Geldpreise | Ort/Zeit) */ @Composable -fun BewerbeTabContent() { - var selectedIndex by remember { mutableIntStateOf(0) } - val bewerbe = remember { sampleBewerbe() } +fun BewerbeTabContent( + viewModel: BewerbViewModel, + turnierId: Long, +) { + val state by viewModel.state.collectAsState() + // Dialog-ViewModel für "Bewerb anlegen" val bewerbDialogVm = remember { BewerbAnlegenViewModel() } val bewerbDialogState by bewerbDialogVm.state.collectAsState() @@ -43,6 +53,19 @@ fun BewerbeTabContent() { BewerbeAktionsSpalte( modifier = Modifier.width(140.dp).fillMaxHeight(), 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() @@ -57,7 +80,7 @@ fun BewerbeTabContent() { verticalAlignment = Alignment.CenterVertically, ) { OutlinedButton( - onClick = {}, + onClick = { viewModel.send(BewerbIntent.Refresh) }, modifier = Modifier.height(32.dp), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), ) { @@ -70,20 +93,22 @@ fun BewerbeTabContent() { color = PrimaryBlue, ) { Text( - text = "${bewerbe.size} Bewerbe", + text = "${state.list.size} Bewerbe", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Medium, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), ) } - OutlinedButton( - onClick = {}, - modifier = Modifier.height(32.dp), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), - ) { - Text("Filtern", fontSize = 12.sp) - } + // Suchfeld + OutlinedTextField( + value = state.searchQuery, + onValueChange = { viewModel.send(BewerbIntent.SearchChanged(it)) }, + modifier = Modifier.weight(1f).height(48.dp), + placeholder = { Text("Suche...", fontSize = 12.sp) }, + singleLine = true, + textStyle = TextStyle(fontSize = 12.sp), + ) } // Tabellen-Header @@ -92,11 +117,11 @@ fun BewerbeTabContent() { // Tabellen-Zeilen LazyColumn(modifier = Modifier.fillMaxSize()) { - itemsIndexed(bewerbe) { index, bewerb -> + itemsIndexed(state.filtered) { _, item -> BewerbeTableRow( - bewerb = bewerb, - isSelected = index == selectedIndex, - onClick = { selectedIndex = index }, + bewerb = item.toUiModel(), + isSelected = state.selectedId == item.id, + onClick = { viewModel.send(BewerbIntent.Select(item.id)) }, ) HorizontalDivider(color = Color(0xFFE5E7EB)) } @@ -106,8 +131,9 @@ fun BewerbeTabContent() { VerticalDivider() // ── Rechtes Detail-Panel ────────────────────────────────────────────── + val selectedItem = state.list.find { it.id == state.selectedId } BewerbeDetailPanel( - bewerb = bewerbe.getOrNull(selectedIndex), + bewerb = selectedItem?.toUiModel(), 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, + 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 @@ -197,6 +284,8 @@ private fun BewerbeTableRow(bewerb: BewerbUiModel, isSelected: Boolean, onClick: private fun BewerbeAktionsSpalte( modifier: Modifier = Modifier, onBewerbEinfuegen: () -> Unit = {}, + onZnsImport: () -> Unit = {}, + onGenerateStartliste: () -> Unit = {}, ) { Column( modifier = modifier.padding(8.dp), @@ -206,12 +295,14 @@ private fun BewerbeAktionsSpalte( AktionsBtn("Änderungen\nRückgängig") HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) AktionsBtn("Bewerb\nEinfügen", onClick = onBewerbEinfuegen) + AktionsBtn("ZNS Import", onClick = onZnsImport) AktionsBtn("Bewerb\nLöschen") AktionsBtn("Bewerb Teilen") HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) AktionsBtn("Bewerb nach\noben verschieben") AktionsBtn("Bewerb nach\nunten verschieben") HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + AktionsBtn("Startliste\nGenerieren", onClick = onGenerateStartliste) AktionsBtn("Startliste\nBearbeiten") AktionsBtn("Startliste\nDrucken") AktionsBtn("Ergebnisliste\nBearbeiten") @@ -230,6 +321,74 @@ private fun AktionsBtn(label: String, onClick: () -> Unit = {}) { } } +@Composable +private fun ZnsImportPreviewDialog( + bewerbe: List, + 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 private fun BewerbAnlegenDialog( state: BewerbAnlegenState, diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt index 66dabdba..8acc81b3 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt @@ -1,5 +1,6 @@ package at.mocode.turnier.feature.presentation +import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -98,7 +99,9 @@ fun TurnierDetailScreen( veranstalterLogoUrl = veranstalterLogoUrl, ) 1 -> OrganisationTabContent() - 2 -> BewerbeTabContent() + 2 -> Box(modifier = Modifier.fillMaxSize()) { + Text("BEWERBE Tab (Anbindung in Arbeit)", modifier = Modifier.align(Alignment.Center)) + } 3 -> ArtikelTabContent() 4 -> AbrechnungTabContent() 5 -> NennungenTabContent() diff --git a/frontend/shells/meldestelle-desktop/build.gradle.kts b/frontend/shells/meldestelle-desktop/build.gradle.kts index 298b7fae..b2db0525 100644 --- a/frontend/shells/meldestelle-desktop/build.gradle.kts +++ b/frontend/shells/meldestelle-desktop/build.gradle.kts @@ -49,6 +49,7 @@ kotlin { implementation(projects.frontend.core.sync) implementation(projects.frontend.core.localDb) implementation(projects.frontend.core.auth) + implementation(projects.core.znsParser) // Feature-Module implementation(projects.frontend.features.nennungFeature) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt index 8379d6f0..5f9bbfce 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/preview/ScreenPreviews.kt @@ -3,6 +3,7 @@ package at.mocode.desktop.screens.preview import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable 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.VeranstalterDetailScreen import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen @@ -112,8 +113,13 @@ fun PreviewTurnierOrganisationTab() { @ComponentPreview @Composable fun PreviewTurnierBewerbeTab() { + val mockRepo = object : BewerbRepository { + override suspend fun listByTurnier(turnierId: Long): List = emptyList() + override suspend fun importBewerbe(turnierId: Long, bewerbe: List): Result = Result.success(Unit) + } + val vm = BewerbViewModel(mockRepo, 1L) MaterialTheme { - BewerbeTabContent() + BewerbeTabContent(viewModel = vm, turnierId = 1L) } }