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:
+115
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
@@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:** LAN‑Sync (ADR‑0022) und Offline‑First Desktop↔Backend Konzept definiert und verlinkt.
|
* [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).
|
* [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)
|
||||||
|
|||||||
+76
@@ -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}") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -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)
|
||||||
|
|||||||
+176
-17
@@ -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,
|
||||||
|
|||||||
+4
-1
@@ -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)
|
||||||
|
|||||||
+7
-1
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user