Compare commits
5 Commits
faccd7eb3b
...
b8e5065d6a
| Author | SHA1 | Date | |
|---|---|---|---|
| b8e5065d6a | |||
| 3fe850d914 | |||
| 9d08cb0f72 | |||
| 4e8ed21ac0 | |||
| 1a77bc92cc |
16
backend/infrastructure/zns-importer/build.gradle.kts
Normal file
16
backend/infrastructure/zns-importer/build.gradle.kts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlinJvm)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(projects.platform.platformBom))
|
||||
implementation(projects.core.coreUtils)
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.znsParser)
|
||||
implementation(projects.backend.services.clubs.clubsDomain)
|
||||
implementation(projects.backend.services.persons.personsDomain)
|
||||
implementation(projects.backend.services.horses.horsesDomain)
|
||||
implementation(projects.backend.services.officials.officialsDomain)
|
||||
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package at.mocode.zns.importer
|
||||
|
||||
/**
|
||||
* Ergebnis eines ZNS-Imports.
|
||||
*
|
||||
* Enthält Zähler für importierte, aktualisierte und übersprungene Datensätze
|
||||
* sowie eine Liste von Fehlern/Warnungen, die während des Imports aufgetreten sind.
|
||||
*/
|
||||
data class ZnsImportResult(
|
||||
val vereineImportiert: Int = 0,
|
||||
val vereineAktualisiert: Int = 0,
|
||||
val reiterImportiert: Int = 0,
|
||||
val reiterAktualisiert: Int = 0,
|
||||
val pferdeImportiert: Int = 0,
|
||||
val pferdeAktualisiert: Int = 0,
|
||||
val richterImportiert: Int = 0,
|
||||
val richterAktualisiert: Int = 0,
|
||||
val fehler: List<String> = emptyList(),
|
||||
val warnungen: List<String> = emptyList()
|
||||
) {
|
||||
val gesamtImportiert: Int
|
||||
get() = vereineImportiert + reiterImportiert + pferdeImportiert + richterImportiert
|
||||
|
||||
val gesamtAktualisiert: Int
|
||||
get() = vereineAktualisiert + reiterAktualisiert + pferdeAktualisiert + richterAktualisiert
|
||||
|
||||
val hatFehler: Boolean
|
||||
get() = fehler.isNotEmpty()
|
||||
|
||||
fun zusammenfassung(): String =
|
||||
"ZNS-Import abgeschlossen: " +
|
||||
"$gesamtImportiert neu importiert, $gesamtAktualisiert aktualisiert. " +
|
||||
"Vereine: ${vereineImportiert}N/${vereineAktualisiert}U, " +
|
||||
"Reiter: ${reiterImportiert}N/${reiterAktualisiert}U, " +
|
||||
"Pferde: ${pferdeImportiert}N/${pferdeAktualisiert}U, " +
|
||||
"Richter: ${richterImportiert}N/${richterAktualisiert}U. " +
|
||||
if (hatFehler) "${fehler.size} Fehler aufgetreten." else "Keine Fehler."
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.zns.importer
|
||||
|
||||
import at.mocode.clubs.domain.repository.VereinRepository
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.officials.domain.repository.FunktionaerRepository
|
||||
import at.mocode.persons.domain.repository.ReiterRepository
|
||||
import at.mocode.zns.parser.ZnsLegacyParsers
|
||||
import java.io.InputStream
|
||||
import java.nio.charset.Charset
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Orchestrator für den ZNS-Stammdaten-Import.
|
||||
*
|
||||
* Nimmt eine ZNS-ZIP-Datei entgegen, entpackt sie in-memory, liest die
|
||||
* enthaltenen .DAT-Dateien mit CP850-Encoding und persistiert die geparsten
|
||||
* Domänenobjekte über die jeweiligen Repositories (Upsert-Logik).
|
||||
*
|
||||
* Die Verarbeitungsreihenfolge ist fix:
|
||||
* 1. VEREIN01.DAT → DomVerein (via VereinRepository)
|
||||
* 2. LIZENZ01.DAT → DomReiter (via ReiterRepository)
|
||||
* 3. PFERDE01.DAT → DomPferd (via HorseRepository)
|
||||
* 4. RICHT01.DAT → DomFunktionaer (via FunktionaerRepository)
|
||||
*
|
||||
* Dieser Service hat **keine** Spring-Abhängigkeit und kann daher sowohl
|
||||
* im Backend (REST-Upload) als auch in der Compose Desktop App (Offline-Import)
|
||||
* verwendet werden.
|
||||
*
|
||||
* @param vereinRepository Repository für Vereine.
|
||||
* @param reiterRepository Repository für Reiter/Personen.
|
||||
* @param horseRepository Repository für Pferde.
|
||||
* @param funktionaerRepository Repository für Funktionäre/Richter.
|
||||
*/
|
||||
class ZnsImportService(
|
||||
private val vereinRepository: VereinRepository,
|
||||
private val reiterRepository: ReiterRepository,
|
||||
private val horseRepository: HorseRepository,
|
||||
private val funktionaerRepository: FunktionaerRepository
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val CP850 = Charset.forName("Cp850")
|
||||
|
||||
private const val FILE_VEREIN = "VEREIN01.DAT"
|
||||
private const val FILE_LIZENZ = "LIZENZ01.DAT"
|
||||
private const val FILE_PFERDE = "PFERDE01.DAT"
|
||||
private const val FILE_RICHT = "RICHT01.DAT"
|
||||
}
|
||||
|
||||
/**
|
||||
* Importiert eine ZNS-ZIP-Datei aus einem [InputStream].
|
||||
*
|
||||
* @param zipInputStream Der InputStream der ZIP-Datei.
|
||||
* @return [ZnsImportResult] mit Statistiken und eventuellen Fehlern.
|
||||
*/
|
||||
suspend fun importiereZip(zipInputStream: InputStream): ZnsImportResult {
|
||||
val dateien = mutableMapOf<String, List<String>>()
|
||||
ZipInputStream(zipInputStream).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name.uppercase().substringAfterLast("/")
|
||||
if (name in setOf(FILE_VEREIN, FILE_LIZENZ, FILE_PFERDE, FILE_RICHT)) {
|
||||
dateien[name] = zip.readBytes().toString(CP850).lines()
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
|
||||
val fehler = mutableListOf<String>()
|
||||
val warnungen = mutableListOf<String>()
|
||||
|
||||
val (vereineNeu, vereineUpd) = importiereVereine(dateien[FILE_VEREIN] ?: emptyList(), fehler)
|
||||
val (reiterNeu, reiterUpd) = importiereReiter(dateien[FILE_LIZENZ] ?: emptyList(), fehler, warnungen)
|
||||
val (pferdeNeu, pferdeUpd) = importierePferde(dateien[FILE_PFERDE] ?: emptyList(), fehler)
|
||||
val (richterNeu, richterUpd) = importiereRichter(dateien[FILE_RICHT] ?: emptyList(), fehler, warnungen)
|
||||
|
||||
return ZnsImportResult(
|
||||
vereineImportiert = vereineNeu,
|
||||
vereineAktualisiert = vereineUpd,
|
||||
reiterImportiert = reiterNeu,
|
||||
reiterAktualisiert = reiterUpd,
|
||||
pferdeImportiert = pferdeNeu,
|
||||
pferdeAktualisiert = pferdeUpd,
|
||||
richterImportiert = richterNeu,
|
||||
richterAktualisiert = richterUpd,
|
||||
fehler = fehler,
|
||||
warnungen = warnungen
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private suspend fun importiereVereine(
|
||||
zeilen: List<String>,
|
||||
fehler: MutableList<String>
|
||||
): Pair<Int, Int> {
|
||||
var neu = 0
|
||||
var aktualisiert = 0
|
||||
zeilen.forEachIndexed { index, zeile ->
|
||||
runCatching {
|
||||
val verein = ZnsLegacyParsers.parseVerein(zeile) ?: return@forEachIndexed
|
||||
val vorhanden = vereinRepository.findByVereinsNummer(verein.vereinsNummer)
|
||||
if (vorhanden == null) {
|
||||
vereinRepository.save(verein)
|
||||
neu++
|
||||
} else {
|
||||
vereinRepository.save(
|
||||
vorhanden.copy(
|
||||
name = verein.name,
|
||||
kurzname = verein.kurzname,
|
||||
bundesland = verein.bundesland,
|
||||
ort = verein.ort,
|
||||
plz = verein.plz,
|
||||
strasse = verein.strasse,
|
||||
oepsRegionNummer = verein.oepsRegionNummer,
|
||||
istAktiv = verein.istAktiv,
|
||||
datenQuelle = verein.datenQuelle
|
||||
).withUpdatedTimestamp()
|
||||
)
|
||||
aktualisiert++
|
||||
}
|
||||
}.onFailure { e ->
|
||||
fehler.add("$FILE_VEREIN Zeile ${index + 1}: ${e.message}")
|
||||
}
|
||||
}
|
||||
return Pair(neu, aktualisiert)
|
||||
}
|
||||
|
||||
private suspend fun importiereReiter(
|
||||
zeilen: List<String>,
|
||||
fehler: MutableList<String>,
|
||||
warnungen: MutableList<String>
|
||||
): Pair<Int, Int> {
|
||||
var neu = 0
|
||||
var aktualisiert = 0
|
||||
zeilen.forEachIndexed { index, zeile ->
|
||||
runCatching {
|
||||
val reiter = ZnsLegacyParsers.parseLizenz(zeile) ?: return@forEachIndexed
|
||||
val vorhanden = reiterRepository.findBySatznummer(reiter.satznummer)
|
||||
if (vorhanden == null) {
|
||||
reiterRepository.save(reiter)
|
||||
neu++
|
||||
} else {
|
||||
reiterRepository.save(
|
||||
vorhanden.copy(
|
||||
vorname = reiter.vorname,
|
||||
nachname = reiter.nachname,
|
||||
vereinsName = reiter.vereinsName,
|
||||
nation = reiter.nation,
|
||||
lizenzKlasse = reiter.lizenzKlasse,
|
||||
istAktiv = reiter.istAktiv,
|
||||
datenQuelle = reiter.datenQuelle
|
||||
).withUpdatedTimestamp()
|
||||
)
|
||||
aktualisiert++
|
||||
}
|
||||
}.onFailure { e ->
|
||||
fehler.add("$FILE_LIZENZ Zeile ${index + 1}: ${e.message}")
|
||||
}
|
||||
}
|
||||
return Pair(neu, aktualisiert)
|
||||
}
|
||||
|
||||
private suspend fun importierePferde(
|
||||
zeilen: List<String>,
|
||||
fehler: MutableList<String>
|
||||
): Pair<Int, Int> {
|
||||
var neu = 0
|
||||
var aktualisiert = 0
|
||||
zeilen.forEachIndexed { index, zeile ->
|
||||
runCatching {
|
||||
val pferd = ZnsLegacyParsers.parsePferd(zeile) ?: return@forEachIndexed
|
||||
val vorhanden = pferd.lebensnummer
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { horseRepository.findByLebensnummer(it) }
|
||||
if (vorhanden == null) {
|
||||
horseRepository.save(pferd)
|
||||
neu++
|
||||
} else {
|
||||
horseRepository.save(
|
||||
vorhanden.copy(
|
||||
pferdeName = pferd.pferdeName,
|
||||
geschlecht = pferd.geschlecht,
|
||||
geburtsdatum = pferd.geburtsdatum,
|
||||
rasse = pferd.rasse,
|
||||
lebensnummer = pferd.lebensnummer,
|
||||
oepsNummer = pferd.oepsNummer,
|
||||
istAktiv = pferd.istAktiv,
|
||||
datenQuelle = pferd.datenQuelle
|
||||
).withUpdatedTimestamp()
|
||||
)
|
||||
aktualisiert++
|
||||
}
|
||||
}.onFailure { e ->
|
||||
fehler.add("$FILE_PFERDE Zeile ${index + 1}: ${e.message}")
|
||||
}
|
||||
}
|
||||
return Pair(neu, aktualisiert)
|
||||
}
|
||||
|
||||
private suspend fun importiereRichter(
|
||||
zeilen: List<String>,
|
||||
fehler: MutableList<String>,
|
||||
warnungen: MutableList<String>
|
||||
): Pair<Int, Int> {
|
||||
var neu = 0
|
||||
var aktualisiert = 0
|
||||
zeilen.forEachIndexed { index, zeile ->
|
||||
runCatching {
|
||||
val richter = ZnsLegacyParsers.parseRichter(zeile) ?: return@forEachIndexed
|
||||
val richterNummer = richter.richterNummer ?: run {
|
||||
warnungen.add("$FILE_RICHT Zeile ${index + 1}: Keine RichterNummer – übersprungen.")
|
||||
return@forEachIndexed
|
||||
}
|
||||
val vorhanden = funktionaerRepository.findByRichterNummer(richterNummer)
|
||||
if (vorhanden == null) {
|
||||
funktionaerRepository.save(richter)
|
||||
neu++
|
||||
} else {
|
||||
funktionaerRepository.save(
|
||||
vorhanden.copy(
|
||||
vorname = richter.vorname,
|
||||
nachname = richter.nachname,
|
||||
vereinsNummer = richter.vereinsNummer,
|
||||
richterNummer = richter.richterNummer,
|
||||
istAktiv = richter.istAktiv,
|
||||
datenQuelle = richter.datenQuelle
|
||||
).withUpdatedTimestamp()
|
||||
)
|
||||
aktualisiert++
|
||||
}
|
||||
}.onFailure { e ->
|
||||
fehler.add("$FILE_RICHT Zeile ${index + 1}: ${e.message}")
|
||||
}
|
||||
}
|
||||
return Pair(neu, aktualisiert)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
package at.mocode.zns.importer
|
||||
|
||||
import at.mocode.clubs.domain.model.DomVerein
|
||||
import at.mocode.clubs.domain.repository.VereinRepository
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.officials.domain.model.DomFunktionaer
|
||||
import at.mocode.officials.domain.repository.FunktionaerRepository
|
||||
import at.mocode.persons.domain.model.DomReiter
|
||||
import at.mocode.persons.domain.repository.ReiterRepository
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.charset.Charset
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
@OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
class ZnsImportServiceTest {
|
||||
|
||||
private val vereinRepository = mockk<VereinRepository>()
|
||||
private val reiterRepository = mockk<ReiterRepository>()
|
||||
private val horseRepository = mockk<HorseRepository>()
|
||||
private val funktionaerRepository = mockk<FunktionaerRepository>()
|
||||
|
||||
private lateinit var service: ZnsImportService
|
||||
|
||||
private val cp850 = Charset.forName("Cp850")
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
service = ZnsImportService(vereinRepository, reiterRepository, horseRepository, funktionaerRepository)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Erstellt eine in-memory ZIP-Datei mit den übergebenen Einträgen. */
|
||||
private fun buildZip(vararg entries: Pair<String, String>): ByteArrayInputStream {
|
||||
val baos = ByteArrayOutputStream()
|
||||
ZipOutputStream(baos).use { zip ->
|
||||
for ((name, content) in entries) {
|
||||
zip.putNextEntry(ZipEntry(name))
|
||||
zip.write(content.toByteArray(cp850))
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return ByteArrayInputStream(baos.toByteArray())
|
||||
}
|
||||
|
||||
/** Erzeugt eine gültige VEREIN01.DAT-Zeile (mind. 30 Zeichen). */
|
||||
private fun vereinZeile(
|
||||
nummer: String = "0001",
|
||||
name: String = "Testverein Wien"
|
||||
): String {
|
||||
// Stelle 1-4: Vereinsnummer, Stelle 5-34: Name (30 Zeichen)
|
||||
return nummer.padEnd(4) + name.padEnd(30)
|
||||
}
|
||||
|
||||
/** Erzeugt eine gültige LIZENZ01.DAT-Zeile (mind. 200 Zeichen). */
|
||||
private fun lizenzZeile(
|
||||
satznummer: String = "123456",
|
||||
nachname: String = "Mustermann",
|
||||
vorname: String = "Max"
|
||||
): String {
|
||||
// Stelle 1-6: Satznummer, 7-56: Nachname (50), 57-81: Vorname (25)
|
||||
return satznummer.padEnd(6) +
|
||||
nachname.padEnd(50) +
|
||||
vorname.padEnd(25) +
|
||||
" ".repeat(200) // Rest auffüllen
|
||||
}
|
||||
|
||||
/** Erzeugt eine gültige PFERDE01.DAT-Zeile (mind. 211 Zeichen). */
|
||||
private fun pferdeZeile(
|
||||
name: String = "Blitz",
|
||||
lebensnummer: String = "AT123456"
|
||||
): String {
|
||||
// Stelle 1-4: Kopfnummer, 5-34: Name (30), 35-43: Lebensnummer (9), 44: Geschlecht, 45-48: Geburtsjahr, 202-211: Satznummer
|
||||
val base = "0001" +
|
||||
name.padEnd(30) +
|
||||
lebensnummer.padEnd(9) +
|
||||
"W" + // Geschlecht: Wallach
|
||||
"2015" + // Geburtsjahr
|
||||
" ".repeat(157) // Auffüllen bis Stelle 201
|
||||
return base + "SAT0000001".padEnd(10) // Satznummer ab Stelle 202
|
||||
}
|
||||
|
||||
/** Erzeugt eine gültige RICHT01.DAT-Zeile (mind. 83 Zeichen). */
|
||||
private fun richterZeile(
|
||||
satznummer: String = "R00001",
|
||||
name: String = "Huber, Anna"
|
||||
): String {
|
||||
// Stelle 1: Typ, 2-7: Satznummer (6), 8-82: Name (75)
|
||||
return "R" + satznummer.padEnd(6) + name.padEnd(75)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `importiereZip - neue Vereine werden gespeichert`() = runTest {
|
||||
val zip = buildZip("VEREIN01.DAT" to vereinZeile())
|
||||
|
||||
coEvery { vereinRepository.findByVereinsNummer(any()) } returns null
|
||||
coEvery { vereinRepository.save(any()) } answers { firstArg() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.vereineImportiert).isEqualTo(1)
|
||||
assertThat(result.vereineAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
coVerify(exactly = 1) { vereinRepository.save(any<DomVerein>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - bestehende Vereine werden aktualisiert`() = runTest {
|
||||
val zip = buildZip("VEREIN01.DAT" to vereinZeile(name = "Neuer Name"))
|
||||
val vorhanden = DomVerein(vereinsNummer = "0001", name = "Alter Name")
|
||||
|
||||
coEvery { vereinRepository.findByVereinsNummer("0001") } returns vorhanden
|
||||
coEvery { vereinRepository.save(any()) } answers { firstArg() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.vereineImportiert).isEqualTo(0)
|
||||
assertThat(result.vereineAktualisiert).isEqualTo(1)
|
||||
coVerify(exactly = 1) { vereinRepository.save(match { it.name == "Neuer Name" }) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - neue Reiter werden gespeichert`() = runTest {
|
||||
val zip = buildZip("LIZENZ01.DAT" to lizenzZeile())
|
||||
|
||||
coEvery { reiterRepository.findBySatznummer(any()) } returns null
|
||||
coEvery { reiterRepository.save(any()) } answers { firstArg() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.reiterImportiert).isEqualTo(1)
|
||||
assertThat(result.reiterAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
coVerify(exactly = 1) { reiterRepository.save(any<DomReiter>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - neue Pferde werden gespeichert`() = runTest {
|
||||
val zip = buildZip("PFERDE01.DAT" to pferdeZeile())
|
||||
|
||||
coEvery { horseRepository.findByLebensnummer(any()) } returns null
|
||||
coEvery { horseRepository.save(any()) } answers { firstArg() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.pferdeImportiert).isEqualTo(1)
|
||||
assertThat(result.pferdeAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
coVerify(exactly = 1) { horseRepository.save(any<DomPferd>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - neue Richter werden gespeichert`() = runTest {
|
||||
val zip = buildZip("RICHT01.DAT" to richterZeile())
|
||||
|
||||
coEvery { funktionaerRepository.findByRichterNummer(any()) } returns null
|
||||
coEvery { funktionaerRepository.save(any()) } answers { firstArg() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.richterImportiert).isEqualTo(1)
|
||||
assertThat(result.richterAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
coVerify(exactly = 1) { funktionaerRepository.save(any<DomFunktionaer>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - vollstaendiger Import aller vier Dateien`() = runTest {
|
||||
val zip = buildZip(
|
||||
"VEREIN01.DAT" to vereinZeile(),
|
||||
"LIZENZ01.DAT" to lizenzZeile(),
|
||||
"PFERDE01.DAT" to pferdeZeile(),
|
||||
"RICHT01.DAT" to richterZeile()
|
||||
)
|
||||
|
||||
coEvery { vereinRepository.findByVereinsNummer(any()) } returns null
|
||||
coEvery { vereinRepository.save(any()) } answers { firstArg() }
|
||||
coEvery { reiterRepository.findBySatznummer(any()) } returns null
|
||||
coEvery { reiterRepository.save(any()) } answers { firstArg() }
|
||||
coEvery { horseRepository.findByLebensnummer(any()) } returns null
|
||||
coEvery { horseRepository.save(any()) } answers { firstArg() }
|
||||
coEvery { funktionaerRepository.findByRichterNummer(any()) } returns null
|
||||
coEvery { funktionaerRepository.save(any()) } answers { firstArg() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.gesamtImportiert).isEqualTo(4)
|
||||
assertThat(result.gesamtAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
assertThat(result.hatFehler).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - leere ZIP ergibt Null-Ergebnis ohne Fehler`() = runTest {
|
||||
val zip = buildZip()
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.gesamtImportiert).isEqualTo(0)
|
||||
assertThat(result.gesamtAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.horses.infrastructure.persistence
|
||||
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import org.jetbrains.exposed.v1.core.*
|
||||
import org.jetbrains.exposed.v1.core.statements.UpdateBuilder
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import kotlin.time.Clock
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlin.uuid.toJavaUuid
|
||||
import kotlin.uuid.toKotlinUuid
|
||||
|
||||
/**
|
||||
* Exposed-basierte Implementierung des HorseRepository (v1-API).
|
||||
*/
|
||||
class HorseRepositoryImpl : HorseRepository {
|
||||
|
||||
override suspend fun findById(id: Uuid): DomPferd? = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.id eq id.toJavaUuid() }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByLebensnummer(lebensnummer: String): DomPferd? = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByChipNummer(chipNummer: String): DomPferd? = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByPassNummer(passNummer: String): DomPferd? = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByOepsNummer(oepsNummer: String): DomPferd? = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByFeiNummer(feiNummer: String): DomPferd? = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByName(searchTerm: String, limit: Int): List<DomPferd> = transaction {
|
||||
val pattern = "%$searchTerm%"
|
||||
HorseTable.selectAll().where { HorseTable.pferdeName like pattern }
|
||||
.limit(limit)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean): List<DomPferd> = transaction {
|
||||
HorseTable.selectAll().where {
|
||||
(HorseTable.besitzerId eq ownerId.toJavaUuid()).let {
|
||||
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
|
||||
}
|
||||
}.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean): List<DomPferd> =
|
||||
transaction {
|
||||
HorseTable.selectAll().where {
|
||||
(HorseTable.verantwortlichePersonId eq responsiblePersonId.toJavaUuid()).let {
|
||||
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
|
||||
}
|
||||
}.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByGeschlecht(
|
||||
geschlecht: PferdeGeschlechtE,
|
||||
activeOnly: Boolean,
|
||||
limit: Int
|
||||
): List<DomPferd> = transaction {
|
||||
HorseTable.selectAll().where {
|
||||
(HorseTable.geschlecht eq geschlecht).let {
|
||||
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
|
||||
}
|
||||
}.limit(limit).map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByRasse(rasse: String, activeOnly: Boolean, limit: Int): List<DomPferd> = transaction {
|
||||
HorseTable.selectAll().where {
|
||||
(HorseTable.rasse eq rasse).let {
|
||||
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
|
||||
}
|
||||
}.limit(limit).map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean): List<DomPferd> = transaction {
|
||||
HorseTable.selectAll()
|
||||
.map { rowToDomPferd(it) }
|
||||
.filter { it.geburtsdatum?.year == birthYear && (!activeOnly || it.istAktiv) }
|
||||
}
|
||||
|
||||
override suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean): List<DomPferd> =
|
||||
transaction {
|
||||
HorseTable.selectAll()
|
||||
.map { rowToDomPferd(it) }
|
||||
.filter {
|
||||
val year = it.geburtsdatum?.year
|
||||
year != null && year in fromYear..toYear && (!activeOnly || it.istAktiv)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun findAllActive(limit: Int): List<DomPferd> = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.istAktiv eq true }
|
||||
.limit(limit)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findOepsRegistered(activeOnly: Boolean): List<DomPferd> = transaction {
|
||||
HorseTable.selectAll().where {
|
||||
HorseTable.oepsNummer.isNotNull().let {
|
||||
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
|
||||
}
|
||||
}.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findFeiRegistered(activeOnly: Boolean): List<DomPferd> = transaction {
|
||||
HorseTable.selectAll().where {
|
||||
HorseTable.feiNummer.isNotNull().let {
|
||||
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
|
||||
}
|
||||
}.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun save(horse: DomPferd): DomPferd = transaction {
|
||||
val existing = HorseTable.selectAll()
|
||||
.where { HorseTable.id eq horse.pferdId.toJavaUuid() }
|
||||
.singleOrNull()
|
||||
if (existing == null) {
|
||||
HorseTable.insert { applyToStatement(it, horse) }
|
||||
} else {
|
||||
HorseTable.update({ HorseTable.id eq horse.pferdId.toJavaUuid() }) {
|
||||
applyToStatement(it, horse.copy(updatedAt = Clock.System.now()))
|
||||
}
|
||||
}
|
||||
horse
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Uuid): Boolean = transaction {
|
||||
HorseTable.deleteWhere { HorseTable.id eq id.toJavaUuid() } > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByLebensnummer(lebensnummer: String): Boolean = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }.count() > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByChipNummer(chipNummer: String): Boolean = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }.count() > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByPassNummer(passNummer: String): Boolean = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }.count() > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByOepsNummer(oepsNummer: String): Boolean = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }.count() > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByFeiNummer(feiNummer: String): Boolean = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }.count() > 0
|
||||
}
|
||||
|
||||
override suspend fun countActive(): Long = transaction {
|
||||
HorseTable.selectAll().where { HorseTable.istAktiv eq true }.count()
|
||||
}
|
||||
|
||||
override suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean): Long = transaction {
|
||||
HorseTable.selectAll().where {
|
||||
(HorseTable.besitzerId eq ownerId.toJavaUuid()).let {
|
||||
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
|
||||
}
|
||||
}.count()
|
||||
}
|
||||
|
||||
override suspend fun countOepsRegistered(activeOnly: Boolean): Long = transaction {
|
||||
HorseTable.selectAll().where {
|
||||
HorseTable.oepsNummer.isNotNull().let {
|
||||
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
|
||||
}
|
||||
}.count()
|
||||
}
|
||||
|
||||
override suspend fun countFeiRegistered(activeOnly: Boolean): Long = transaction {
|
||||
HorseTable.selectAll().where {
|
||||
HorseTable.feiNummer.isNotNull().let {
|
||||
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
|
||||
}
|
||||
}.count()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun rowToDomPferd(row: ResultRow): DomPferd = DomPferd(
|
||||
pferdId = row[HorseTable.id].value.toKotlinUuid(),
|
||||
pferdeName = row[HorseTable.pferdeName],
|
||||
geschlecht = row[HorseTable.geschlecht],
|
||||
geburtsdatum = row[HorseTable.geburtsdatum],
|
||||
rasse = row[HorseTable.rasse],
|
||||
farbe = row[HorseTable.farbe],
|
||||
besitzerId = row[HorseTable.besitzerId]?.toKotlinUuid(),
|
||||
verantwortlichePersonId = row[HorseTable.verantwortlichePersonId]?.toKotlinUuid(),
|
||||
zuechterName = row[HorseTable.zuechterName],
|
||||
zuchtbuchNummer = row[HorseTable.zuchtbuchNummer],
|
||||
lebensnummer = row[HorseTable.lebensnummer],
|
||||
chipNummer = row[HorseTable.chipNummer],
|
||||
passNummer = row[HorseTable.passNummer],
|
||||
oepsNummer = row[HorseTable.oepsNummer],
|
||||
feiNummer = row[HorseTable.feiNummer],
|
||||
vaterName = row[HorseTable.vaterName],
|
||||
mutterName = row[HorseTable.mutterName],
|
||||
mutterVaterName = row[HorseTable.mutterVaterName],
|
||||
stockmass = row[HorseTable.stockmass],
|
||||
istAktiv = row[HorseTable.istAktiv],
|
||||
bemerkungen = row[HorseTable.bemerkungen],
|
||||
datenQuelle = row[HorseTable.datenQuelle],
|
||||
createdAt = row[HorseTable.createdAt],
|
||||
updatedAt = row[HorseTable.updatedAt]
|
||||
)
|
||||
|
||||
private fun applyToStatement(statement: UpdateBuilder<*>, horse: DomPferd) {
|
||||
statement[HorseTable.id] = horse.pferdId.toJavaUuid()
|
||||
statement[HorseTable.pferdeName] = horse.pferdeName
|
||||
statement[HorseTable.geschlecht] = horse.geschlecht
|
||||
statement[HorseTable.geburtsdatum] = horse.geburtsdatum
|
||||
statement[HorseTable.rasse] = horse.rasse
|
||||
statement[HorseTable.farbe] = horse.farbe
|
||||
statement[HorseTable.besitzerId] = horse.besitzerId?.toJavaUuid()
|
||||
statement[HorseTable.verantwortlichePersonId] = horse.verantwortlichePersonId?.toJavaUuid()
|
||||
statement[HorseTable.zuechterName] = horse.zuechterName
|
||||
statement[HorseTable.zuchtbuchNummer] = horse.zuchtbuchNummer
|
||||
statement[HorseTable.lebensnummer] = horse.lebensnummer
|
||||
statement[HorseTable.chipNummer] = horse.chipNummer
|
||||
statement[HorseTable.passNummer] = horse.passNummer
|
||||
statement[HorseTable.oepsNummer] = horse.oepsNummer
|
||||
statement[HorseTable.feiNummer] = horse.feiNummer
|
||||
statement[HorseTable.vaterName] = horse.vaterName
|
||||
statement[HorseTable.mutterName] = horse.mutterName
|
||||
statement[HorseTable.mutterVaterName] = horse.mutterVaterName
|
||||
statement[HorseTable.stockmass] = horse.stockmass
|
||||
statement[HorseTable.istAktiv] = horse.istAktiv
|
||||
statement[HorseTable.bemerkungen] = horse.bemerkungen
|
||||
statement[HorseTable.datenQuelle] = horse.datenQuelle
|
||||
statement[HorseTable.createdAt] = horse.createdAt
|
||||
statement[HorseTable.updatedAt] = horse.updatedAt
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlinJvm)
|
||||
alias(libs.plugins.kotlinSpring)
|
||||
alias(libs.plugins.spring.boot)
|
||||
alias(libs.plugins.spring.dependencyManagement)
|
||||
}
|
||||
|
||||
springBoot {
|
||||
mainClass.set("at.mocode.zns.import.service.ZnsImportServiceApplicationKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.platform.platformDependencies)
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
implementation(projects.backend.infrastructure.znsImporter)
|
||||
implementation(projects.backend.services.clubs.clubsDomain)
|
||||
implementation(projects.backend.services.clubs.clubsInfrastructure)
|
||||
implementation(projects.backend.services.persons.personsDomain)
|
||||
implementation(projects.backend.services.persons.personsInfrastructure)
|
||||
implementation(projects.backend.services.horses.horsesDomain)
|
||||
implementation(projects.backend.services.horses.horsesInfrastructure)
|
||||
implementation(projects.backend.services.officials.officialsDomain)
|
||||
implementation(projects.backend.services.officials.officialsInfrastructure)
|
||||
|
||||
implementation(libs.spring.boot.starter.web)
|
||||
implementation(libs.spring.boot.starter.validation)
|
||||
implementation(libs.spring.boot.starter.actuator)
|
||||
|
||||
implementation(libs.exposed.core)
|
||||
implementation(libs.exposed.dao)
|
||||
implementation(libs.exposed.jdbc)
|
||||
implementation(libs.exposed.migration.jdbc)
|
||||
implementation(libs.exposed.kotlin.datetime)
|
||||
implementation(libs.hikari.cp)
|
||||
|
||||
runtimeOnly(libs.postgresql.driver)
|
||||
testRuntimeOnly(libs.h2.driver)
|
||||
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.spring.boot.starter.test)
|
||||
testImplementation(libs.logback.classic)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package at.mocode.zns.import.service
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.ComponentScan
|
||||
|
||||
@SpringBootApplication
|
||||
@ComponentScan(
|
||||
basePackages = [
|
||||
"at.mocode.zns.import.service",
|
||||
"at.mocode.clubs.infrastructure",
|
||||
"at.mocode.persons.infrastructure",
|
||||
"at.mocode.horses.infrastructure",
|
||||
"at.mocode.officials.infrastructure"
|
||||
]
|
||||
)
|
||||
class ZnsImportServiceApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<ZnsImportServiceApplication>(*args)
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package at.mocode.zns.import.service.api
|
||||
|
||||
import at.mocode.zns.import.service.job.ImportJob
|
||||
import at.mocode.zns.import.service.job.ImportJobRegistry
|
||||
import at.mocode.zns.import.service.job.ZnsImportOrchestrator
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
data class ImportStartResponse(val jobId: String)
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/import/zns")
|
||||
class ZnsImportController(
|
||||
private val orchestrator: ZnsImportOrchestrator,
|
||||
private val jobRegistry: ImportJobRegistry
|
||||
) {
|
||||
|
||||
/**
|
||||
* POST /api/v1/import/zns
|
||||
* Nimmt eine .zip oder .dat Datei entgegen und startet den asynchronen Import.
|
||||
* Rückgabe: 202 Accepted mit JobId.
|
||||
*/
|
||||
@PostMapping(consumes = ["multipart/form-data"])
|
||||
fun starteImport(@RequestParam("file") file: MultipartFile): ResponseEntity<ImportStartResponse> {
|
||||
val job = jobRegistry.erstelleJob()
|
||||
orchestrator.starteImport(job.jobId, file.bytes)
|
||||
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ImportStartResponse(job.jobId))
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/import/zns/{jobId}/status
|
||||
* Gibt den aktuellen Fortschritt und Statusmeldungen zurück.
|
||||
*/
|
||||
@GetMapping("/{jobId}/status")
|
||||
fun holeStatus(@PathVariable jobId: String): ResponseEntity<ImportJob> {
|
||||
val job = jobRegistry.findeJob(jobId)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
return ResponseEntity.ok(job)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package at.mocode.zns.import.service.config
|
||||
|
||||
import at.mocode.clubs.domain.repository.VereinRepository
|
||||
import at.mocode.clubs.infrastructure.persistence.ExposedVereinRepository
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.horses.infrastructure.persistence.HorseRepositoryImpl
|
||||
import at.mocode.officials.domain.repository.FunktionaerRepository
|
||||
import at.mocode.officials.infrastructure.persistence.ExposedFunktionaerRepository
|
||||
import at.mocode.persons.domain.repository.ReiterRepository
|
||||
import at.mocode.persons.infrastructure.persistence.ExposedReiterRepository
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@Configuration
|
||||
class RepositoryConfiguration {
|
||||
|
||||
@Bean
|
||||
fun vereinRepository(): VereinRepository = ExposedVereinRepository()
|
||||
|
||||
@Bean
|
||||
fun reiterRepository(): ReiterRepository = ExposedReiterRepository()
|
||||
|
||||
@Bean
|
||||
fun horseRepository(): HorseRepository = HorseRepositoryImpl()
|
||||
|
||||
@Bean
|
||||
fun funktionaerRepository(): FunktionaerRepository = ExposedFunktionaerRepository()
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package at.mocode.zns.import.service.config
|
||||
|
||||
import at.mocode.clubs.infrastructure.persistence.VereinTable
|
||||
import at.mocode.horses.infrastructure.persistence.HorseTable
|
||||
import at.mocode.officials.infrastructure.persistence.FunktionaerTable
|
||||
import at.mocode.persons.infrastructure.persistence.ReiterTable
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.jetbrains.exposed.v1.jdbc.Database
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.context.annotation.Profile
|
||||
|
||||
@Configuration
|
||||
@Profile("dev")
|
||||
class ZnsImportDatabaseConfiguration(
|
||||
@Value("\${spring.datasource.url}") private val jdbcUrl: String,
|
||||
@Value("\${spring.datasource.username}") private val username: String,
|
||||
@Value("\${spring.datasource.password}") private val password: String
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(ZnsImportDatabaseConfiguration::class.java)
|
||||
|
||||
@PostConstruct
|
||||
fun initializeDatabase() {
|
||||
log.info("Initialisiere Datenbank-Schema für ZNS-Import-Service...")
|
||||
Database.connect(jdbcUrl, user = username, password = password)
|
||||
transaction {
|
||||
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(
|
||||
VereinTable, ReiterTable, HorseTable, FunktionaerTable
|
||||
)
|
||||
statements.forEach { exec(it) }
|
||||
log.info("Datenbank-Schema erfolgreich initialisiert ({} Statements)", statements.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package at.mocode.zns.import.service.job
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
enum class ImportJobStatus { AUSSTEHEND, ENTPACKEN, LADE_VEREINE, LADE_REITER, LADE_PFERDE, LADE_RICHTER, ABGESCHLOSSEN, FEHLER }
|
||||
|
||||
data class ImportJob(
|
||||
val jobId: String,
|
||||
var status: ImportJobStatus = ImportJobStatus.AUSSTEHEND,
|
||||
var fortschritt: Int = 0,
|
||||
var meldungen: MutableList<String> = mutableListOf(),
|
||||
var fehler: MutableList<String> = mutableListOf(),
|
||||
var warnungen: MutableList<String> = mutableListOf()
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@Component
|
||||
class ImportJobRegistry {
|
||||
private val jobs = ConcurrentHashMap<String, ImportJob>()
|
||||
|
||||
fun erstelleJob(): ImportJob {
|
||||
val job = ImportJob(jobId = Uuid.random().toString())
|
||||
jobs[job.jobId] = job
|
||||
return job
|
||||
}
|
||||
|
||||
fun findeJob(jobId: String): ImportJob? = jobs[jobId]
|
||||
|
||||
fun aktualisiereStatus(jobId: String, status: ImportJobStatus, meldung: String? = null, fortschritt: Int? = null) {
|
||||
jobs[jobId]?.let { job ->
|
||||
job.status = status
|
||||
meldung?.let { job.meldungen.add(it) }
|
||||
fortschritt?.let { job.fortschritt = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package at.mocode.zns.import.service.job
|
||||
|
||||
import at.mocode.clubs.domain.repository.VereinRepository
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.officials.domain.repository.FunktionaerRepository
|
||||
import at.mocode.persons.domain.repository.ReiterRepository
|
||||
import at.mocode.zns.importer.ZnsImportService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class ZnsImportOrchestrator(
|
||||
private val vereinRepository: VereinRepository,
|
||||
private val reiterRepository: ReiterRepository,
|
||||
private val horseRepository: HorseRepository,
|
||||
private val funktionaerRepository: FunktionaerRepository,
|
||||
private val jobRegistry: ImportJobRegistry
|
||||
) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
fun starteImport(jobId: String, zipBytes: ByteArray) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.ENTPACKEN, "Entpacke ZIP-Datei...", 5)
|
||||
|
||||
val service = ZnsImportService(vereinRepository, reiterRepository, horseRepository, funktionaerRepository)
|
||||
|
||||
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.LADE_VEREINE, "Lade Vereine...", 20)
|
||||
val result = service.importiereZip(zipBytes.inputStream())
|
||||
|
||||
jobRegistry.aktualisiereStatus(
|
||||
jobId, ImportJobStatus.ABGESCHLOSSEN,
|
||||
"Import abgeschlossen: ${result.vereineImportiert} Vereine, " +
|
||||
"${result.reiterImportiert} Reiter, ${result.pferdeImportiert} Pferde, " +
|
||||
"${result.richterImportiert} Richter importiert.", 100
|
||||
)
|
||||
|
||||
jobRegistry.findeJob(jobId)?.let { job ->
|
||||
job.fehler.addAll(result.fehler)
|
||||
job.warnungen.addAll(result.warnungen)
|
||||
}
|
||||
}.onFailure { ex ->
|
||||
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.FEHLER, "Fehler: ${ex.message}")
|
||||
jobRegistry.findeJob(jobId)?.fehler?.add(ex.message ?: "Unbekannter Fehler")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
server:
|
||||
port: ${ZNS_IMPORT_SERVER_PORT:8095}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: zns-import-service
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/pg-meldestelle-db}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:pg-user}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:pg-password}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
servlet:
|
||||
multipart:
|
||||
enabled: true
|
||||
max-file-size: 50MB
|
||||
max-request-size: 50MB
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
|
||||
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
app:
|
||||
service-name: ${spring.application.name}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package at.mocode.core.utils.parser
|
||||
|
||||
/**
|
||||
* A simple utility to parse fixed-width strings based on 1-based start positions and lengths.
|
||||
* This is particularly useful for parsing legacy data formats like the OePS ZNS formats.
|
||||
*/
|
||||
class FixedWidthLineReader(private val line: String) {
|
||||
|
||||
/**
|
||||
* Extracts a string from the given 1-based start position with the given length.
|
||||
* Trims leading and trailing whitespace.
|
||||
* Returns an empty string if the position is out of bounds.
|
||||
*/
|
||||
fun getString(start1Based: Int, length: Int): String {
|
||||
val start0Based = start1Based - 1
|
||||
if (start0Based >= line.length) return ""
|
||||
|
||||
val end0Based = minOf(start0Based + length, line.length)
|
||||
return line.substring(start0Based, end0Based).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a string and parses it as a Long.
|
||||
* Returns null if the field is empty or cannot be parsed.
|
||||
*/
|
||||
fun getLongOrNull(start1Based: Int, length: Int): Long? {
|
||||
val str = getString(start1Based, length)
|
||||
return str.toLongOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a string and parses it as an Int.
|
||||
* Returns null if the field is empty or cannot be parsed.
|
||||
*/
|
||||
fun getIntOrNull(start1Based: Int, length: Int): Int? {
|
||||
val str = getString(start1Based, length)
|
||||
return str.toIntOrNull()
|
||||
}
|
||||
}
|
||||
30
core/zns-parser/build.gradle.kts
Normal file
30
core/zns-parser/build.gradle.kts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
|
||||
// Domänen-Modelle für das Parsing
|
||||
implementation(projects.backend.services.clubs.clubsDomain)
|
||||
implementation(projects.backend.services.persons.personsDomain)
|
||||
implementation(projects.backend.services.horses.horsesDomain)
|
||||
implementation(projects.backend.services.officials.officialsDomain)
|
||||
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
}
|
||||
}
|
||||
commonTest {
|
||||
dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
package at.mocode.zns.parser
|
||||
|
||||
import at.mocode.clubs.domain.model.DomVerein
|
||||
import at.mocode.core.domain.model.DatenQuelleE
|
||||
import at.mocode.core.domain.model.LizenzKlasseE
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import at.mocode.core.utils.parser.FixedWidthLineReader
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.officials.domain.model.DomFunktionaer
|
||||
import at.mocode.persons.domain.model.DomReiter
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Parsers for the legacy ZNS .dat files (Fixed-Width format, CP850 encoded).
|
||||
*
|
||||
* This module is independent of backend infrastructure so it can be used
|
||||
* by both the backend API and the Compose Desktop app (Offline-First).
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
object ZnsLegacyParsers {
|
||||
|
||||
/**
|
||||
* Parses a line from VEREIN01.DAT.
|
||||
*/
|
||||
fun parseVerein(line: String): DomVerein? {
|
||||
if (line.isBlank() || line.length < 5) return null
|
||||
|
||||
val reader = FixedWidthLineReader(line)
|
||||
|
||||
val vereinsNummer = reader.getString(1, 4)
|
||||
val vereinsName = reader.getString(5, 50)
|
||||
|
||||
if (vereinsNummer.isBlank() || vereinsName.isBlank()) return null
|
||||
|
||||
return DomVerein(
|
||||
vereinsNummer = vereinsNummer,
|
||||
name = vereinsName,
|
||||
datenQuelle = DatenQuelleE.IMPORT_ZNS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a line from LIZENZ01.DAT.
|
||||
*/
|
||||
fun parseLizenz(line: String): DomReiter? {
|
||||
if (line.isBlank() || line.length < 57) return null
|
||||
|
||||
val reader = FixedWidthLineReader(line)
|
||||
|
||||
val satznummer = reader.getString(1, 6)
|
||||
if (satznummer.isBlank()) return null
|
||||
|
||||
val nachname = reader.getString(7, 50)
|
||||
val vorname = reader.getString(57, 25)
|
||||
val vereinsName = reader.getString(84, 50)
|
||||
val nation = reader.getString(134, 3)
|
||||
|
||||
val lizenzString = reader.getString(137, 4)
|
||||
val lizenz = mapLizenz(lizenzString)
|
||||
|
||||
val sperrlisteFlag = reader.getString(200, 1)
|
||||
val gesperrt = sperrlisteFlag == "S"
|
||||
|
||||
return DomReiter(
|
||||
personId = Uuid.random(),
|
||||
satznummer = satznummer,
|
||||
nachname = nachname,
|
||||
vorname = vorname,
|
||||
vereinsName = vereinsName.ifBlank { null },
|
||||
nation = nation.ifBlank { null },
|
||||
lizenzKlasse = lizenz,
|
||||
istAktiv = !gesperrt,
|
||||
datenQuelle = DatenQuelleE.IMPORT_ZNS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a line from PFERDE01.DAT.
|
||||
*/
|
||||
fun parsePferd(line: String): DomPferd? {
|
||||
if (line.isBlank() || line.length < 202) return null
|
||||
|
||||
val reader = FixedWidthLineReader(line)
|
||||
|
||||
val satznummer = reader.getString(202, 10)
|
||||
if (satznummer.isBlank()) return null
|
||||
|
||||
val name = reader.getString(5, 30)
|
||||
val kopfnummer = reader.getString(1, 4)
|
||||
val lebensnummer = reader.getString(35, 9)
|
||||
|
||||
val geschlechtChar = reader.getString(44, 1)
|
||||
val geschlecht = mapGeschlecht(geschlechtChar)
|
||||
|
||||
val geburtsjahr = reader.getIntOrNull(45, 4)
|
||||
val geburtsdatum = geburtsjahr?.let { LocalDate(it, 1, 1) }
|
||||
|
||||
return DomPferd(
|
||||
pferdeName = name,
|
||||
geschlecht = geschlecht,
|
||||
geburtsdatum = geburtsdatum,
|
||||
lebensnummer = lebensnummer.ifBlank { null },
|
||||
datenQuelle = DatenQuelleE.IMPORT_ZNS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a line from RICHT01.DAT.
|
||||
*/
|
||||
fun parseRichter(line: String): DomFunktionaer? {
|
||||
if (line.isBlank() || line.length < 8) return null
|
||||
|
||||
val reader = FixedWidthLineReader(line)
|
||||
|
||||
val satznummer = reader.getString(2, 6)
|
||||
if (satznummer.isBlank()) return null
|
||||
|
||||
val fullName = reader.getString(8, 75)
|
||||
val parts = fullName.split(",").map { it.trim() }
|
||||
val nachname = parts.getOrNull(0) ?: fullName
|
||||
val vorname = parts.getOrNull(1) ?: ""
|
||||
|
||||
return DomFunktionaer(
|
||||
richterNummer = satznummer,
|
||||
nachname = nachname,
|
||||
vorname = vorname,
|
||||
datenQuelle = DatenQuelleE.IMPORT_ZNS
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapLizenz(lizenz: String): LizenzKlasseE {
|
||||
return when (lizenz.uppercase()) {
|
||||
"R1" -> LizenzKlasseE.R1
|
||||
"R2" -> LizenzKlasseE.R2
|
||||
"R3" -> LizenzKlasseE.R3
|
||||
"RD1" -> LizenzKlasseE.RD1
|
||||
"RD2" -> LizenzKlasseE.RD2
|
||||
"RD3" -> LizenzKlasseE.RD3
|
||||
"JN" -> LizenzKlasseE.JN
|
||||
"JG" -> LizenzKlasseE.JG
|
||||
"YR" -> LizenzKlasseE.YR
|
||||
else -> LizenzKlasseE.LIZENZFREI
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapGeschlecht(geschlecht: String): PferdeGeschlechtE {
|
||||
return when (geschlecht.uppercase()) {
|
||||
"W" -> PferdeGeschlechtE.WALLACH
|
||||
"S" -> PferdeGeschlechtE.STUTE
|
||||
"H" -> PferdeGeschlechtE.HENGST
|
||||
else -> PferdeGeschlechtE.UNBEKANNT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package at.mocode.zns.parser
|
||||
|
||||
import at.mocode.core.domain.model.LizenzKlasseE
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class ZnsLegacyParsersTest {
|
||||
|
||||
@Test
|
||||
fun `parseVerein should extract VEREIN01 correctly`() {
|
||||
val line = "1234Reitverein Test "
|
||||
val result = ZnsLegacyParsers.parseVerein(line)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("1234", result.vereinsNummer)
|
||||
assertEquals("Reitverein Test", result.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseLizenz should extract LIZENZ01 correctly`() {
|
||||
val sb = StringBuilder()
|
||||
sb.append("123456")
|
||||
sb.append("Mustermann ")
|
||||
sb.append("Max ")
|
||||
sb.append("01")
|
||||
sb.append("Reitverein Wien ")
|
||||
sb.append("AUT")
|
||||
sb.append("R1 ")
|
||||
|
||||
while (sb.length < 199) {
|
||||
sb.append(" ")
|
||||
}
|
||||
sb.append("S")
|
||||
|
||||
val result = ZnsLegacyParsers.parseLizenz(sb.toString())
|
||||
assertNotNull(result)
|
||||
assertEquals("123456", result.satznummer)
|
||||
assertEquals("Mustermann", result.nachname)
|
||||
assertEquals("Max", result.vorname)
|
||||
assertEquals("Reitverein Wien", result.vereinsName)
|
||||
assertEquals(LizenzKlasseE.R1, result.lizenzKlasse)
|
||||
assertEquals(false, result.istAktiv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parsePferd should extract PFERDE01 correctly`() {
|
||||
val sb = StringBuilder()
|
||||
sb.append("A123")
|
||||
sb.append("Black Beauty ")
|
||||
sb.append("123456789")
|
||||
sb.append("W")
|
||||
sb.append("2010")
|
||||
|
||||
while (sb.length < 201) {
|
||||
sb.append(" ")
|
||||
}
|
||||
sb.append("0000000001")
|
||||
|
||||
val result = ZnsLegacyParsers.parsePferd(sb.toString())
|
||||
assertNotNull(result)
|
||||
assertEquals("Black Beauty", result.pferdeName)
|
||||
assertEquals("123456789", result.lebensnummer)
|
||||
assertEquals(PferdeGeschlechtE.WALLACH, result.geschlecht)
|
||||
assertEquals(2010, result.geburtsdatum?.year)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseRichter should extract RICHT01 correctly`() {
|
||||
val line =
|
||||
"X123456Richter, Peter GA "
|
||||
val result = ZnsLegacyParsers.parseRichter(line)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("123456", result.richterNummer)
|
||||
assertEquals("Richter", result.nachname)
|
||||
assertEquals("Peter", result.vorname)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,18 +2,18 @@
|
|||
type: Roadmap
|
||||
status: ACTIVE
|
||||
owner: Lead Architect
|
||||
last_update: 2026-03-24
|
||||
last_update: 2026-03-25
|
||||
---
|
||||
|
||||
# MASTER ROADMAP: Meldestelle-Biest
|
||||
|
||||
🏗️ **[Lead Architect]** | 24. März 2026
|
||||
🏗️ **[Lead Architect]** | 25. März 2026
|
||||
|
||||
**Strategisches Ziel:**
|
||||
Entwicklung einer ÖTO-konformen, offline-fähigen Turnier-Meldestelle als Compose Desktop App (KMP).
|
||||
Vollständige Self-Hosted Infrastruktur (Gitea, Pangolin, Zora). Datensouveränität, Offline-First, saubere Wissensbasis.
|
||||
|
||||
**Aktueller technischer Stand (24.03.2026):**
|
||||
**Aktueller technischer Stand (25.03.2026):**
|
||||
* **Infrastruktur:** ✅ "Zora" (MS-R1, ARM64) ist live. Gitea & Registry laufen.
|
||||
* **Networking:** ✅ Pangolin Tunnel ersetzt Cloudflare.
|
||||
* **CI/CD:** ✅ Gitea Actions mit ARM64-Runner (VM 102) aktiv. Docker-Publish Pipeline grün.
|
||||
|
|
@ -125,11 +125,30 @@ und über definierte Schnittstellen kommunizieren.
|
|||
|
||||
#### 📜 Agent: ÖTO/FEI Rulebook Expert
|
||||
|
||||
* [ ] **Voltigieren (CVN):** Abteilungs-Trennungsregeln aus B-Teil § 400 ff. auswerten (offene Frage #3).
|
||||
* [ ] **Fahren (CAN):** Starter-Schwellenwerte jenseits der Reitertreffen-Regel klären (offene Frage #4).
|
||||
* [x] **Voltigieren (CVN):** Abteilungs-Trennungsregeln aus B-Teil § 400 ff. ausgewertet (offene Frage #3 teilweise
|
||||
geklärt).
|
||||
→ B IV enthält keine eigenen Regeln – verweist auf OEPS-Reglement CVN. § 39 Abs. 1 gilt nicht für CVN. § 39 Abs.
|
||||
2 (> 80 Starter) gilt als Fallback.
|
||||
→ `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md` (Abschnitt 2.6)
|
||||
* [x] **Fahren (CAN):** Starter-Schwellenwerte jenseits der Reitertreffen-Regel geklärt (offene Frage #4 teilweise
|
||||
geklärt).
|
||||
→ B VII enthält keine eigenen Regeln – verweist auf OEPS-Reglement „Turnierordnung für Gespanne". § 39 Abs. 1 gilt
|
||||
nicht für CAN. § 39 Abs. 2 (> 80 Starter) gilt als Fallback. Einzige gesicherte Lizenzregel: § 850 Abs. 9 (F1+ bei
|
||||
Fahrertreffen).
|
||||
→ `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md` (Abschnitt 2.5)
|
||||
* [x] **Warn-Logik:** Spezifikation der `competition-context` Warn-Logik für Abteilungs-Schwellenwerte.
|
||||
→ `docs/03_Domain/02_Reference/OETO_Regelwerk/Warn-Logik-Spezifikation-competition-context.md`
|
||||
|
||||
#### 🧹 Agent: Curator & Lead Architect (ZNS-Importer)
|
||||
|
||||
* [x] **ZNS-Importer (MVP) – Phase 1 & 2:** `core:zns-parser` (KMP), `ZnsLegacyParsers` (alle 4 Dateitypen, CP850),
|
||||
`ZnsImportService` (Orchestrator, ZIP in-memory, Upsert), Unit-Tests grün.
|
||||
→ Detaillierte Planung: `docs/01_Architecture/Roadmap_ZNS_Importer.md`
|
||||
* [x] Backend-Infrastruktur & CP850 Parser (Phase 1 – Parser/Modul)
|
||||
* [x] Domain-Mapping & Upsert in DB (Phase 2)
|
||||
* [ ] REST-API & Job-Management (Phase 1 – Controller/Job-Registry)
|
||||
* [ ] Frontend-Integration mit File-Picker & Status-Polling (Phase 3)
|
||||
|
||||
---
|
||||
|
||||
## 3. Geplante Phasen
|
||||
|
|
@ -192,3 +211,4 @@ und über definierte Schnittstellen kommunizieren.
|
|||
| CI/CD | `.gitea/workflows/docker-publish.yaml` |
|
||||
| Agent Playbooks | `docs/04_Agents/Playbooks/` |
|
||||
| ADR-Verzeichnis | `docs/01_Architecture/adr/` |
|
||||
| ZNS-Importer Roadmap | `docs/01_Architecture/Roadmap_ZNS_Importer.md` |
|
||||
|
|
|
|||
101
docs/01_Architecture/Roadmap_ZNS_Importer.md
Normal file
101
docs/01_Architecture/Roadmap_ZNS_Importer.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
type: Roadmap
|
||||
status: IN_PROGRESS
|
||||
owner: Curator
|
||||
last_update: 2026-03-25
|
||||
---
|
||||
|
||||
# Roadmap: ZNS-Importer (MVP)
|
||||
|
||||
🧹 **[Curator]** | 25. März 2026
|
||||
|
||||
**Kontext:**
|
||||
Um den `registration-context` und `actor-context` unter realistischen Bedingungen testen zu können, benötigen wir echte
|
||||
Stammdaten (Reiter, Pferde, Vereine, Funktionäre). Diese Daten werden vom ÖPS (Österreichischer Pferdesportverband) in
|
||||
Form einer `ZNS.zip` bereitgestellt.
|
||||
|
||||
**Ziel:**
|
||||
Entwicklung eines asynchronen, robusten Importers für die Legacy-ZNS-Daten (`ZNS.zip`), der über die Compose-Desktop-App
|
||||
gesteuert wird und die Daten persistent im Backend (`actor-context`) ablegt.
|
||||
|
||||
---
|
||||
|
||||
## 1. Spezifikationen & Rahmenbedingungen
|
||||
|
||||
* **Dateiformat:** Fixed-Width ASCII (Feste Spaltenbreiten, keine Trennzeichen).
|
||||
* **Encoding:** Zwingend **Codepage 850 (CP850)**. (Achtung: Umlaute!).
|
||||
* **Architektur-Muster:** Asynchroner Upload & Processing (Job-Pattern).
|
||||
* **Import-Reihenfolge (Abhängigkeiten beachten!):**
|
||||
1. `VEREIN01.dat` (Vereine)
|
||||
2. `LIZENZ01.dat` (Reiter/Lizenzen)
|
||||
3. `PFERDE01.dat` (Pferde - benötigt Vereins-ID)
|
||||
4. `RICHT01.dat` (Richter/Parcoursbauer)
|
||||
|
||||
---
|
||||
|
||||
## 2. Phasen & Aufgabenverteilung
|
||||
|
||||
### Phase 1: Backend-Infrastruktur & Parsing (👷 Backend Developer)
|
||||
|
||||
* [x] **API Design:**
|
||||
* `POST /api/v1/import/zns` (Multipart/form-data, nimmt `.zip` oder `.dat` entgegen).
|
||||
* Rückgabe: `202 Accepted` mit einer `JobId` (UUID).
|
||||
* `GET /api/v1/import/zns/{jobId}/status` (Gibt aktuellen Fortschritt und Statusmeldungen zurück).
|
||||
* Implementiert in `backend:services:zns-import:zns-import-service` (`ZnsImportController`). ✅
|
||||
* [x] **Job-Management:**
|
||||
* Thread-sichere In-Memory-Registry (`ImportJobRegistry`, `ConcurrentHashMap`) implementiert.
|
||||
* Status-Enum: `AUSSTEHEND`, `ENTPACKEN`, `LADE_VEREINE`, `LADE_REITER`, `LADE_PFERDE`, `LADE_RICHTER`,
|
||||
`ABGESCHLOSSEN`, `FEHLER`. ✅
|
||||
* [x] **Unzip-Service:**
|
||||
* ZIP-Entpackung in-memory implementiert (`ZnsImportService`).
|
||||
* [x] **Legacy-Parser (CP850 Fixed-Width):**
|
||||
* `ZnsLegacyParsers` in `core:zns-parser` (KMP-Modul) implementiert.
|
||||
* Alle 4 Dateitypen (VEREIN01, LIZENZ01, PFERDE01, RICHT01) bytegenau gemappt. 4 Unit-Tests grün.
|
||||
|
||||
### Phase 2: Domain-Mapping & Persistenz (👷 Backend Developer)
|
||||
|
||||
* [x] **Mapper-Logik:**
|
||||
* `DomVerein`, `DomReiter`, `DomPferd`, `DomFunktionaer` vollständig gemappt.
|
||||
* *Sonderfälle umgesetzt:*
|
||||
* `PFERDE01.dat`: Ausländische Systemnummern werden ignoriert. ✅
|
||||
* `LIZENZ01.dat`: Sperrlisten-Flag (`S`) korrekt auf `DomReiter` gemappt. ✅
|
||||
* [x] **Upsert-Strategie (DB):**
|
||||
* `ZnsImportService` implementiert find + save Logik (Upsert). 7 Unit-Tests grün.
|
||||
* Fehler pro Zeile werden gesammelt (kein Abbruch bei Einzelfehlern). `ZnsImportResult` mit Zählern & Fehlerlisten.
|
||||
|
||||
### Phase 3: Frontend-Integration (🎨 Frontend Expert)
|
||||
|
||||
* [x] **UI-Komponenten (Compose Desktop):**
|
||||
* `StammdatenImportScreen` in `meldestelle-desktop` erstellt. ✅
|
||||
* Nativer File-Picker (`JFileChooser`, nur `.zip`) integriert. ✅
|
||||
* `AppScreen.StammdatenImport` + Nav-Rail-Eintrag "Stammdaten-Import" hinzugefügt. ✅
|
||||
* [x] **Netzwerk & State-Management (KMP):**
|
||||
* `ZnsImportViewModel` mit Ktor Multipart-Upload (`POST /api/v1/import/zns`). ✅
|
||||
* Polling-Mechanismus (alle 2 Sekunden, `GET /api/v1/import/zns/{jobId}/status`). ✅
|
||||
* `ZnsImportViewModel` via Koin `viewModel { }` in `desktopModule` registriert. ✅
|
||||
* [x] **User Feedback:**
|
||||
* `LinearProgressIndicator` mit Live-Prozent + `progressDetail`-Text. ✅
|
||||
* `StatusChip` für alle Job-Zustände (AUSSTEHEND → ABGESCHLOSSEN/FEHLER). ✅
|
||||
* Fehler-Banner + scrollbare Fehler-Liste (max. 50 Einträge). ✅
|
||||
|
||||
### Phase 4: Testing & QA (🧐 QA Specialist)
|
||||
|
||||
* [ ] **Test-Datensatz prüfen:**
|
||||
* Einlesen der `docs/OePS/ZNS.zip` und Überprüfung der Umlaute (CP850 Encoding Test).
|
||||
* [ ] **Abhängigkeits-Test:**
|
||||
* Sicherstellen, dass Pferde korrekt ihren Vereinen zugeordnet werden.
|
||||
* [ ] **Edge-Cases:**
|
||||
* Upload einer fehlerhaften Zip-Datei.
|
||||
* Abbruch des Uploads/Imports.
|
||||
|
||||
---
|
||||
|
||||
## 3. Offene Fragen / Risiken
|
||||
|
||||
* Werden die Legacy-Daten in der Datenbank (PostgreSQL) mit UUIDs oder mit den ZNS-Satznummern als Primary Keys
|
||||
abgelegt?
|
||||
* *Architektur-Beschluss:* Wir sollten interne UUIDs als Primary Keys verwenden und die ZNS-Nummern als eindeutige
|
||||
Business-Keys (`UNIQUE CONSTRAINT`) ablegen.
|
||||
* Wie gehen wir mit gelöschten Datensätzen aus dem ZNS um?
|
||||
* *Vorerst:* Der ZNS-Import ist ein "Add/Update"-Only Vorgang. Löschungen müssen manuell oder in einer späteren Phase
|
||||
synchronisiert werden.
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
type: Reference
|
||||
status: ACTIVE
|
||||
owner: ÖTO/FEI Rulebook Expert
|
||||
last_update: 2026-03-24
|
||||
last_update: 2026-03-25
|
||||
sources:
|
||||
- ÖTO 2026, A-Teil § 39 (Teilen von Bewerben)
|
||||
- ÖTO 2026, B-Teil § 200 ff. (Springen / CSN)
|
||||
|
|
@ -10,11 +10,13 @@ sources:
|
|||
- ÖTO 2026, B-Teil § 300 ff. (Vielseitigkeit / CCN)
|
||||
- ÖTO 2026, B-Teil § 803 (Caprilli)
|
||||
- ÖTO 2026, B-Teil § 850 ff. (Reitertreffen / Fahren)
|
||||
- ÖTO 2026, B-Teil Abschnitt B IV (Voltigieren – Verweis auf OEPS-Reglement CVN)
|
||||
- ÖTO 2026, B-Teil Abschnitt B VII (Fahren – Verweis auf OEPS-Reglement „Turnierordnung für Gespanne")
|
||||
---
|
||||
|
||||
# Abteilungs-Trennungs-Schwellenwerte
|
||||
|
||||
📜 **[ÖTO/FEI Rulebook Expert]** | 24. März 2026
|
||||
📜 **[ÖTO/FEI Rulebook Expert]** | 25. März 2026
|
||||
|
||||
Dieses Dokument ist die **Single Source of Truth** für alle Regeln zur Pflicht- und Kann-Teilung
|
||||
von Bewerben in Abteilungen. Es ergänzt die [Ubiquitous Language](../../01_Glossary/Ubiquitous_Language.md)
|
||||
|
|
@ -163,35 +165,95 @@ Pflicht-Teilung in **mindestens 2 Abteilungen** – unabhängig von der Starterz
|
|||
|
||||
---
|
||||
|
||||
### 2.5 Fahren (CAN) – Reitertreffen
|
||||
### 2.5 Fahren (CAN) – Sparte B VII
|
||||
|
||||
Bei Fahrertreffen (§ 850 ff.) gilt:
|
||||
> ⚠️ **Wichtiger Befund (25.03.2026):** Der ÖTO B-Teil Abschnitt B VII enthält **keine eigenen
|
||||
> Abteilungs-Trennungsregeln** für Fahren. Er verweist vollständig auf das separate
|
||||
> **OEPS-Reglement „Turnierordnung für Gespanne" (CAN)**, das als Bestandteil der ÖTO gilt (§ 1),
|
||||
> aber **nicht im Projekt vorliegt**.
|
||||
|
||||
| Regel | ÖTO-Referenz |
|
||||
|-------------------------------------------------------------------------------------|---------------------|
|
||||
| Fahrer mit Lizenz **höher als F1** werden in einer **separaten Abteilung** gewertet | B-Teil § 850 Abs. 9 |
|
||||
#### 2.5.1 Was aus dem ÖTO A-Teil und § 850 gilt (gesichert)
|
||||
|
||||
| Regel | ÖTO-Referenz |
|
||||
|--------------------------------------------------------------------------------------------------------------------|--------------|
|
||||
| § 39 Abs. 1 gilt **nicht** für Fahren – B VII ist dort **nicht** aufgeführt (nur B I, B II, B III) | § 39 Abs. 1 |
|
||||
| § 39 Abs. 2 gilt **allgemein**: Pflicht-Teilung bei > 80 Startern in einer Abteilung nach Teilung | § 39 Abs. 2 |
|
||||
| Bei **Fahrertreffen** (§ 850): Fahrer mit Lizenz **höher als F1** werden in einer **separaten Abteilung** gewertet | § 850 Abs. 9 |
|
||||
| Diese Lizenz-Abteilungsregel gilt **nur für Treffen**, nicht für reguläre CAN-Turniere | § 850 Abs. 9 |
|
||||
|
||||
#### 2.5.2 Spartenspezifische CAN-Regeln – Status
|
||||
|
||||
| Regelbereich | Status |
|
||||
|------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
|
||||
| Abteilungs-Trennungsregeln für reguläre CAN-Turniere (Dressurfahren, Hindernisfahren, Geländefahren) | ❓ **Unbekannt** – nur im OEPS-Reglement „Turnierordnung für Gespanne" enthalten |
|
||||
| Schwellenwerte für Starter pro Disziplin (CAN) | ❓ **Unbekannt** – nur im OEPS-Reglement enthalten |
|
||||
| Lizenz-Abteilungstrennung (F1/F2/F3) bei regulären Turnieren | ❓ **Unbekannt** – nur im OEPS-Reglement enthalten |
|
||||
|
||||
#### 2.5.3 Implementierungs-Empfehlung (bis CAN-Reglement vorliegt)
|
||||
|
||||
- **Fallback:** Allgemeine § 39 Abs. 2-Schwellenwerte anwenden (> 80 Starter in einer Abteilung → WARN)
|
||||
- **Fahrertreffen:** F1+ in separater Abteilung als strukturelle Pflicht-Teilung implementieren (gesicherte
|
||||
Rechtsgrundlage)
|
||||
- **Keine** weiteren strukturellen Lizenz-Pflicht-Teilungen für CAN-Turniere (mangels Rechtsgrundlage im ÖTO)
|
||||
- CAN-spezifische Warn-Logik für reguläre Turniere als `TODO` markieren bis OEPS-Reglement beschafft ist
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Voltigieren (CVN) – Sparte B IV
|
||||
|
||||
> ⚠️ **Wichtiger Befund (25.03.2026):** Der ÖTO B-Teil Abschnitt B IV enthält **keine eigenen
|
||||
> Abteilungs-Trennungsregeln** für Voltigieren. Er verweist vollständig auf das separate
|
||||
> **OEPS-Reglement „Voltigieren" (CVN)**, das als Bestandteil der ÖTO gilt (§ 1), aber
|
||||
> **nicht im Projekt vorliegt**.
|
||||
|
||||
#### 2.6.1 Was aus dem ÖTO A-Teil gilt (gesichert)
|
||||
|
||||
| Regel | ÖTO-Referenz |
|
||||
|--------------------------------------------------------------------------------------------------------|--------------|
|
||||
| § 39 Abs. 1 gilt **nicht** für Voltigieren – B IV ist dort **nicht** aufgeführt (nur B I, B II, B III) | § 39 Abs. 1 |
|
||||
| § 39 Abs. 2 gilt **allgemein**: Pflicht-Teilung bei > 80 Startern in einer Abteilung nach Teilung | § 39 Abs. 2 |
|
||||
| Keine Lizenz-Pflicht-Teilung (R1 in eigener Abteilung) aus § 39 Abs. 1 für Voltigierbewerbe ableitbar | § 39 Abs. 1 |
|
||||
| Bei Voltigierertreffen (§ 850): **keine** analoge Lizenz-Abteilungsregel wie bei Fahren (F1+) | § 850 Abs. 9 |
|
||||
|
||||
#### 2.6.2 Spartenspezifische CVN-Regeln – Status
|
||||
|
||||
| Regelbereich | Status |
|
||||
|---------------------------------------------------------------------------|-------------------------------------------------------|
|
||||
| Abteilungs-Trennungsregeln nach Disziplin (Gruppe / Einzel / Pas de Deux) | ❓ **Unbekannt** – nur im OEPS-Reglement CVN enthalten |
|
||||
| Schwellenwerte für Starter pro Disziplin | ❓ **Unbekannt** – nur im OEPS-Reglement CVN enthalten |
|
||||
| Lizenz-Abteilungstrennung für Voltigierer | ❓ **Unbekannt** – nur im OEPS-Reglement CVN enthalten |
|
||||
|
||||
#### 2.6.3 Implementierungs-Empfehlung (bis CVN-Reglement vorliegt)
|
||||
|
||||
- **Fallback:** Allgemeine § 39 Abs. 2-Schwellenwerte anwenden (> 80 Starter in einer Abteilung → WARN)
|
||||
- **Keine** strukturelle Lizenz-Pflicht-Teilung für CVN implementieren (mangels Rechtsgrundlage im ÖTO)
|
||||
- CVN-spezifische Warn-Logik als `TODO` markieren bis OEPS-Reglement beschafft und ausgewertet ist
|
||||
|
||||
---
|
||||
|
||||
## 3. Zusammenfassung: Schwellenwert-Matrix
|
||||
|
||||
| Sparte | Prüfungstyp | Schwellenwert | Typ | Kriterium |
|
||||
|--------|-------------------------------|---------------|------|-----------------------|
|
||||
| Alle | Stil- / Springpferdeprüfungen | > 30 Starter | MUSS | Lizenz / Plätze |
|
||||
| Alle | Vielseitigkeitsprüfungen | > 40 Starter | MUSS | Lizenz / Plätze |
|
||||
| Alle | Übrige Springprüfungen | > 80 Starter | MUSS | Lizenz / Plätze |
|
||||
| Alle | Jede Abteilung nach Teilung | > 80 Starter | MUSS | Erneute Teilung |
|
||||
| CDN | Dressurprüfungen | > 30 Starter | KANN | Lizenz / Plätze |
|
||||
| CDN | Dressurpferdeprüfung Klasse A | strukturell | MUSS | Pferdealter (4 / 5–6) |
|
||||
| CSN | Stil-/Idealzeit bis 95 cm | strukturell | MUSS | Lizenz (ohne / R1) |
|
||||
| CSN | Springpferdeprüfung 95–110 cm | strukturell | MUSS | Pferdealter (4 / 5–6) |
|
||||
| CSN | C-NEU bis 95 cm | strukturell | MUSS | Lizenz (ohne / mit) |
|
||||
| CSN | C-NEU ab 100 cm | strukturell | MUSS | Lizenz (R1 / R2+) |
|
||||
| CCN | C-NEU Gelände bis 80 cm | strukturell | MUSS | Lizenz (3 Abt.) |
|
||||
| CCN | C-NEU Gelände ab 90 cm | strukturell | MUSS | Lizenz (2 Abt.) |
|
||||
| CCN | Welcome / 80 cm | strukturell | MUSS | R2+ eigene Abt. |
|
||||
| Alle | Caprilli (§ 803) | strukturell | MUSS | Lizenz (frei / RD1+) |
|
||||
| CAN | Fahrertreffen | strukturell | MUSS | F1+ eigene Abt. |
|
||||
| Sparte | Prüfungstyp | Schwellenwert | Typ | Kriterium |
|
||||
|--------|-------------------------------|---------------|------|----------------------------------------|
|
||||
| Alle | Stil- / Springpferdeprüfungen | > 30 Starter | MUSS | Lizenz / Plätze |
|
||||
| Alle | Vielseitigkeitsprüfungen | > 40 Starter | MUSS | Lizenz / Plätze |
|
||||
| Alle | Übrige Springprüfungen | > 80 Starter | MUSS | Lizenz / Plätze |
|
||||
| Alle | Jede Abteilung nach Teilung | > 80 Starter | MUSS | Erneute Teilung |
|
||||
| CDN | Dressurprüfungen | > 30 Starter | KANN | Lizenz / Plätze |
|
||||
| CDN | Dressurpferdeprüfung Klasse A | strukturell | MUSS | Pferdealter (4 / 5–6) |
|
||||
| CSN | Stil-/Idealzeit bis 95 cm | strukturell | MUSS | Lizenz (ohne / R1) |
|
||||
| CSN | Springpferdeprüfung 95–110 cm | strukturell | MUSS | Pferdealter (4 / 5–6) |
|
||||
| CSN | C-NEU bis 95 cm | strukturell | MUSS | Lizenz (ohne / mit) |
|
||||
| CSN | C-NEU ab 100 cm | strukturell | MUSS | Lizenz (R1 / R2+) |
|
||||
| CCN | C-NEU Gelände bis 80 cm | strukturell | MUSS | Lizenz (3 Abt.) |
|
||||
| CCN | C-NEU Gelände ab 90 cm | strukturell | MUSS | Lizenz (2 Abt.) |
|
||||
| CCN | Welcome / 80 cm | strukturell | MUSS | R2+ eigene Abt. |
|
||||
| Alle | Caprilli (§ 803) | strukturell | MUSS | Lizenz (frei / RD1+) |
|
||||
| CAN | Fahrertreffen (§ 850) | strukturell | MUSS | F1+ eigene Abt. |
|
||||
| CAN | Reguläre CAN-Turniere | > 80 Starter | MUSS | Erneute Teilung (§ 39 Abs. 2 Fallback) |
|
||||
| CAN | Spartenspezifisch | ❓ unbekannt | – | CAN-Reglement (OEPS) fehlt – TODO |
|
||||
| CVN | Alle Voltigierbewerbe | > 80 Starter | MUSS | Erneute Teilung (§ 39 Abs. 2 Fallback) |
|
||||
| CVN | Spartenspezifisch | ❓ unbekannt | – | CVN-Reglement (OEPS) fehlt – TODO |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -238,14 +300,14 @@ Die Schwellenwerte sind **nicht hard-coded**, sondern als konfigurierbare Werte
|
|||
|
||||
## 5. Offene Fragen / Klärungsbedarf
|
||||
|
||||
| # | Frage | Status |
|
||||
|---|-----------------------------------------------------------------------------------------------------------------------|----------|
|
||||
| 1 | Gelten die Schwellenwerte aus § 39 auch für **Reitertreffen** (nicht nur Turniere)? | 🔍 Offen |
|
||||
| 2 | Wie verhält sich die Pflicht-Teilung bei **kombinierten Turnieren** (CDN + CSN am selben Wochenende, § 4)? | 🔍 Offen |
|
||||
| 3 | Gibt es für **Voltigieren (CVN)** eigene Abteilungs-Trennungsregeln? (B-Teil § 400 ff. nicht vollständig ausgewertet) | 🔍 Offen |
|
||||
| 4 | Gibt es für **Fahren (CAN)** eigene Starter-Schwellenwerte jenseits der Reitertreffen-Regel? | 🔍 Offen |
|
||||
| # | Frage | Status |
|
||||
|---|-----------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 1 | Gelten die Schwellenwerte aus § 39 auch für **Reitertreffen** (nicht nur Turniere)? | 🔍 Offen |
|
||||
| 2 | Wie verhält sich die Pflicht-Teilung bei **kombinierten Turnieren** (CDN + CSN am selben Wochenende, § 4)? | 🔍 Offen |
|
||||
| 3 | Gibt es für **Voltigieren (CVN)** eigene Abteilungs-Trennungsregeln? (B-Teil § 400 ff. nicht vollständig ausgewertet) | ✅ **Teilweise geklärt** (25.03.2026): B IV enthält keine eigenen Regeln – verweist auf OEPS-Reglement CVN. § 39 Abs. 1 gilt **nicht** für CVN. § 39 Abs. 2 (> 80 Starter) gilt als Fallback. CVN-Reglement muss beim OEPS beschafft werden für spartenspezifische Regeln. → Siehe Abschnitt 2.6 |
|
||||
| 4 | Gibt es für **Fahren (CAN)** eigene Starter-Schwellenwerte jenseits der Reitertreffen-Regel? | ✅ **Teilweise geklärt** (25.03.2026): B VII enthält keine eigenen Regeln – verweist auf OEPS-Reglement „Turnierordnung für Gespanne". § 39 Abs. 1 gilt **nicht** für CAN. § 39 Abs. 2 (> 80 Starter) gilt als Fallback. Einzige gesicherte Lizenzregel: § 850 Abs. 9 (F1+ bei Fahrertreffen). CAN-Reglement muss beim OEPS beschafft werden. → Siehe Abschnitt 2.5 |
|
||||
|
||||
---
|
||||
|
||||
*Erstellt: 2026-03-24 | Autor: ÖTO/FEI Rulebook Expert (Junie)*
|
||||
*Basiert auf: ÖTO 2026 A-Teil § 39, B-Teil §§ 100, 200, 231, 300, 803, 850*
|
||||
*Erstellt: 2026-03-24 | Zuletzt aktualisiert: 2026-03-25 | Autor: ÖTO/FEI Rulebook Expert (Junie)*
|
||||
*Basiert auf: ÖTO 2026 A-Teil § 39, B-Teil §§ 100, 200, 231, 300, 803, 850, Abschnitt B IV, Abschnitt B VII*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
type: Session Log
|
||||
date: 2026-03-25
|
||||
agent: ÖTO/FEI Rulebook Expert
|
||||
topic: Fahren (CAN) – Starter-Schwellenwerte jenseits der Reitertreffen-Regel klären
|
||||
status: ABGESCHLOSSEN (teilweise – CAN-Reglement fehlt)
|
||||
roadmap_ref: PHASE 4 – offene Frage #4
|
||||
---
|
||||
|
||||
# Session Log: CAN Starter-Schwellenwerte
|
||||
|
||||
📜 **[ÖTO/FEI Rulebook Expert]** | 25. März 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Aufgabe
|
||||
|
||||
Klärung der Frage, ob es für Fahren (CAN) eigene Starter-Schwellenwerte jenseits der
|
||||
Reitertreffen-Regel (§ 850 Abs. 9) gibt (offene Frage #4 aus `Abteilungs-Trennungs-Schwellenwerte.md`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Analysierte Quellen
|
||||
|
||||
| Quelle | Ergebnis |
|
||||
|----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| ÖTO 2026, B-Teil Abschnitt B VII (Fahrprüfungen) | Enthält **keine eigenen Abteilungsregeln** – vollständiger Verweis auf OEPS-Reglement „Turnierordnung für Gespanne" |
|
||||
| ÖTO 2026, A-Teil § 39 Abs. 1 | Gilt **nicht** für Fahren – B VII ist dort nicht aufgeführt (nur B I, B II, B III) |
|
||||
| ÖTO 2026, A-Teil § 39 Abs. 2 | Gilt **allgemein** für alle Sparten inkl. CAN: > 80 Starter in einer Abteilung → Pflicht-Teilung |
|
||||
| ÖTO 2026, B-Teil § 850 Abs. 9 | Fahrer mit Lizenz **höher als F1** werden bei Fahrertreffen in einer **separaten Abteilung** gewertet – gilt nur für Treffen, nicht für reguläre Turniere |
|
||||
| OEPS-Reglement „Turnierordnung für Gespanne" (CAN) | **Nicht im Projekt vorhanden** – muss beim OEPS beschafft werden |
|
||||
|
||||
---
|
||||
|
||||
## 3. Kernergebnisse
|
||||
|
||||
### 3.1 Was gesichert gilt (aus ÖTO A-Teil und § 850)
|
||||
|
||||
- **§ 39 Abs. 1 gilt NICHT für CAN:** Die Lizenz-Pflicht-Teilung ist explizit nur für
|
||||
B I (Dressur), B II (Springen) und B III (Vielseitigkeit) vorgeschrieben.
|
||||
Fahren (B VII) ist dort **nicht** genannt.
|
||||
|
||||
- **§ 39 Abs. 2 gilt als allgemeiner Fallback:** Bei mehr als 80 Startern in einer Abteilung
|
||||
nach Teilung muss diese erneut geteilt werden. Dies gilt für alle Sparten.
|
||||
|
||||
- **Fahrertreffen-Lizenzregel (§ 850 Abs. 9):** Fahrer mit Lizenz höher als F1 werden in
|
||||
einer separaten Abteilung gewertet. Diese Regel gilt **ausschließlich für Treffen**,
|
||||
nicht für reguläre CAN-Turniere.
|
||||
|
||||
- **Strukturelle Parallele zu CVN:** Abschnitt B VII verhält sich identisch zu Abschnitt B IV
|
||||
(Voltigieren) – beide verweisen vollständig auf externe OEPS-Reglements.
|
||||
|
||||
### 3.2 Was unbekannt bleibt (nur im CAN-Reglement)
|
||||
|
||||
- Abteilungs-Trennungsregeln für reguläre CAN-Turniere (Dressurfahren, Hindernisfahren, Geländefahren)
|
||||
- Schwellenwerte für Starter pro Disziplin
|
||||
- Lizenz-Abteilungstrennung (F1/F2/F3) bei regulären Turnieren
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementierungs-Empfehlung
|
||||
|
||||
| Maßnahme | Begründung |
|
||||
|--------------------------------------------------------------------------------|-------------------------------------------------------------|
|
||||
| § 39 Abs. 2-Fallback (> 80 Starter → WARN) für CAN implementieren | Einzige gesicherte Rechtsgrundlage aus ÖTO |
|
||||
| **Fahrertreffen:** F1+ in separater Abteilung als strukturelle Pflicht-Teilung | Gesicherte Rechtsgrundlage: § 850 Abs. 9 |
|
||||
| **Keine** weiteren strukturellen Lizenz-Pflicht-Teilungen für CAN-Turniere | Keine Rechtsgrundlage im ÖTO A-Teil § 39 Abs. 1 |
|
||||
| CAN-spezifische Warn-Logik für reguläre Turniere als `TODO` markieren | CAN-Reglement fehlt – Implementierung nach Beschaffung |
|
||||
| OEPS-Reglement „Turnierordnung für Gespanne" beim OEPS beschaffen | Für vollständige spartenspezifische Auswertung erforderlich |
|
||||
|
||||
---
|
||||
|
||||
## 5. Geänderte Dokumente
|
||||
|
||||
| Dokument | Änderung |
|
||||
|-------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md` | Abschnitt 2.5 (CAN) vollständig überarbeitet; Matrix um CAN-Zeilen ergänzt; offene Frage #4 auf ✅ Teilweise geklärt gesetzt; Quellen-Header und Footer ergänzt |
|
||||
| `docs/01_Architecture/MASTER_ROADMAP.md` | Aufgabe „Fahren (CAN)" auf [x] gesetzt mit Kurzbeschreibung des Ergebnisses |
|
||||
|
||||
---
|
||||
|
||||
## 6. Nächste Schritte
|
||||
|
||||
- [ ] **OEPS-Reglement „Turnierordnung für Gespanne" (CAN) beschaffen** → beim OEPS anfordern oder herunterladen
|
||||
- [ ] Nach Beschaffung: CAN-spezifische Abteilungsregeln auswerten und Abschnitt 2.5 vervollständigen
|
||||
- [ ] **OEPS-Reglement „Voltigieren" (CVN) beschaffen** → offene Frage #3 vollständig klären
|
||||
- [ ] **Offene Fragen #1 und #2** (Reitertreffen-Schwellenwerte, kombinierte Turniere) bei Gelegenheit klären
|
||||
|
||||
---
|
||||
|
||||
*Autor: ÖTO/FEI Rulebook Expert (Junie) | 2026-03-25*
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
type: Session Log
|
||||
date: 2026-03-25
|
||||
agent: ÖTO/FEI Rulebook Expert
|
||||
topic: Voltigieren (CVN) – Abteilungs-Trennungsregeln aus B-Teil § 400 ff. auswerten
|
||||
status: ABGESCHLOSSEN (teilweise – CVN-Reglement fehlt)
|
||||
roadmap_ref: PHASE 4 – offene Frage #3
|
||||
---
|
||||
|
||||
# Session Log: CVN Abteilungs-Trennungsregeln
|
||||
|
||||
📜 **[ÖTO/FEI Rulebook Expert]** | 25. März 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Aufgabe
|
||||
|
||||
Auswertung der Abteilungs-Trennungsregeln für Voltigieren (CVN) aus dem ÖTO B-Teil § 400 ff.
|
||||
(offene Frage #3 aus `Abteilungs-Trennungs-Schwellenwerte.md`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Analysierte Quellen
|
||||
|
||||
| Quelle | Ergebnis |
|
||||
|---------------------------------------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| ÖTO 2026, B-Teil Abschnitt B IV (§ 400 ff.) | Enthält **keine eigenen Abteilungsregeln** – vollständiger Verweis auf OEPS-Reglement „Voltigieren" (CVN) |
|
||||
| ÖTO 2026, A-Teil § 39 Abs. 1 | Gilt **nicht** für Voltigieren – B IV ist dort nicht aufgeführt (nur B I, B II, B III) |
|
||||
| ÖTO 2026, A-Teil § 39 Abs. 2 | Gilt **allgemein** für alle Sparten inkl. CVN: > 80 Starter in einer Abteilung → Pflicht-Teilung |
|
||||
| ÖTO 2026, B-Teil § 850 Abs. 9 | Keine analoge Lizenz-Abteilungsregel für Voltigierertreffen (nur für Fahren: F1+ in eigener Abt.) |
|
||||
| OEPS-Reglement „Voltigieren" (CVN) | **Nicht im Projekt vorhanden** – muss beim OEPS beschafft werden |
|
||||
|
||||
---
|
||||
|
||||
## 3. Kernergebnisse
|
||||
|
||||
### 3.1 Was gesichert gilt (aus ÖTO A-Teil)
|
||||
|
||||
- **§ 39 Abs. 1 gilt NICHT für CVN:** Die Lizenz-Pflicht-Teilung (R1 in eigener Abteilung) ist
|
||||
explizit nur für B I (Dressur), B II (Springen) und B III (Vielseitigkeit) vorgeschrieben.
|
||||
Voltigieren (B IV) ist dort **nicht** genannt.
|
||||
|
||||
- **§ 39 Abs. 2 gilt als allgemeiner Fallback:** Bei mehr als 80 Startern in einer Abteilung
|
||||
nach Teilung muss diese erneut geteilt werden. Dies gilt für alle Sparten.
|
||||
|
||||
- **Keine Voltigierertreffen-Lizenzregel:** § 850 Abs. 9 enthält eine Lizenz-Abteilungsregel
|
||||
nur für Fahren (F1+ in separater Abteilung). Für Voltigierertreffen gibt es keine analoge Regel.
|
||||
|
||||
### 3.2 Was unbekannt bleibt (nur im CVN-Reglement)
|
||||
|
||||
- Abteilungs-Trennungsregeln nach Disziplin (Gruppe / Einzel / Pas de Deux)
|
||||
- Schwellenwerte für Starter pro Disziplin
|
||||
- Lizenz-Abteilungstrennung für Voltigierer
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementierungs-Empfehlung
|
||||
|
||||
| Maßnahme | Begründung |
|
||||
|-------------------------------------------------------------------|-------------------------------------------------------------|
|
||||
| § 39 Abs. 2-Fallback (> 80 Starter → WARN) für CVN implementieren | Einzige gesicherte Rechtsgrundlage aus ÖTO |
|
||||
| **Keine** strukturelle Lizenz-Pflicht-Teilung für CVN | Keine Rechtsgrundlage im ÖTO A-Teil § 39 Abs. 1 |
|
||||
| CVN-spezifische Warn-Logik als `TODO` markieren | CVN-Reglement fehlt – Implementierung nach Beschaffung |
|
||||
| OEPS-Reglement „Voltigieren" beim OEPS beschaffen | Für vollständige spartenspezifische Auswertung erforderlich |
|
||||
|
||||
---
|
||||
|
||||
## 5. Geänderte Dokumente
|
||||
|
||||
| Dokument | Änderung |
|
||||
|-------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md` | Abschnitt 2.6 (CVN) neu hinzugefügt; Matrix um CVN-Zeilen ergänzt; offene Frage #3 auf ✅ Teilweise geklärt gesetzt |
|
||||
| `docs/01_Architecture/MASTER_ROADMAP.md` | Aufgabe „Voltigieren (CVN)" auf [x] gesetzt |
|
||||
|
||||
---
|
||||
|
||||
## 6. Nächste Schritte
|
||||
|
||||
- [ ] **OEPS-Reglement „Voltigieren" (CVN) beschaffen** → beim OEPS anfordern oder herunterladen
|
||||
- [ ] Nach Beschaffung: CVN-spezifische Abteilungsregeln auswerten und Abschnitt 2.6 vervollständigen
|
||||
- [ ] **Offene Frage #4 (Fahren/CAN):** Starter-Schwellenwerte jenseits der Reitertreffen-Regel klären
|
||||
|
||||
---
|
||||
|
||||
*Autor: ÖTO/FEI Rulebook Expert (Junie) | 2026-03-25*
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
date: 2026-03-25
|
||||
type: Session Log
|
||||
agents: [ Lead Architect, Frontend Expert, Curator ]
|
||||
status: ABGESCHLOSSEN
|
||||
---
|
||||
|
||||
# Session Log: Frontend-Architektur-Bereinigung & ZNS-Import Phase 3
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Diese Session umfasste zwei Hauptthemen:
|
||||
|
||||
1. Vollständige Implementierung von **Phase 3 (ZNS-Import Frontend)**
|
||||
2. Analyse und Bereinigung von **5 Frontend-Architektur-Problemen**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: ZNS-Import Frontend (✅ Abgeschlossen)
|
||||
|
||||
### Implementierte Komponenten
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-----------------------------|-----------------------------------------------------------------|
|
||||
| `AppScreen.kt` | `StammdatenImport`-Route + `fromRoute`-Mapping |
|
||||
| `ZnsImportViewModel.kt` | Ktor Multipart-Upload, Polling (2s), Auth-Token-Injection |
|
||||
| `StammdatenImportScreen.kt` | File-Picker (JFileChooser, nur .zip), ProgressBar, Fehler-Liste |
|
||||
| `DesktopMainLayout.kt` | Nav-Rail-Eintrag "Stammdaten-Import" |
|
||||
| `DesktopModule.kt` | ZnsImportViewModel via Koin registriert |
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Bereinigung (✅ Abgeschlossen)
|
||||
|
||||
### Problem 1 – NetworkModule.kt (🔴 Hoch)
|
||||
|
||||
- **Was:** Doppelter `HttpSend`-Interceptor + ~90 Zeilen auskommentierter Debug-Code
|
||||
- **Fix:** Bereinigt auf 83 Zeilen, ein sauberer Interceptor
|
||||
|
||||
### Problem 2 – meldestelle-portal (🟡 Mittel)
|
||||
|
||||
- **Was:** Toter Prototyp (JVM+JS+WASM) ohne Package, ohne DI, mit falschem Fenstertitel
|
||||
- **Fix:** Verzeichnis gelöscht, `settings.gradle.kts` bereinigt
|
||||
- **Hinweis:** Web-Portal ist in MASTER_ROADMAP als zukünftige Phase vorgesehen – wird neu & sauber aufgebaut
|
||||
|
||||
### Problem 3 – AppScreen.fromRoute (🟡 Mittel)
|
||||
|
||||
- **Was:** Fehlende Mappings für parametrisierte Routen (VeranstaltungDetail, TurnierDetail, TurnierNeu)
|
||||
- **Fix:** Regex-Parsing ergänzt
|
||||
|
||||
### Problem 4 – NavigationPort Interface (🟢 Niedrig)
|
||||
|
||||
- **Was:** `navigateToScreen` und `currentScreen` fehlten im Interface
|
||||
- **Fix:** Interface erweitert, `DesktopNavigationPort` implementiert beide Methoden
|
||||
|
||||
### Problem 5 – ZnsImportViewModel Auslagerung (🟢 Niedrig)
|
||||
|
||||
- **Was:** ViewModel mit Business-Logik in der Shell statt im Feature-Modul
|
||||
- **Fix:** Neues Feature-Modul `frontend/features/zns-import-feature` erstellt
|
||||
|
||||
---
|
||||
|
||||
## Build-Fehler behoben
|
||||
|
||||
| Fehler | Fix |
|
||||
|---------------------------------------------------|----------------------------------------------------|
|
||||
| `:frontend:shells:meldestelle-portal` not found | `architecture-tests/build.gradle.kts` aktualisiert |
|
||||
| `libs.ktor.client.content.negotiation` unresolved | → `libs.ktor.client.contentNegotiation` |
|
||||
| `libs.ktor.serialization.kotlinx.json` unresolved | → `libs.ktor.client.serialization.kotlinx.json` |
|
||||
| `libs.androidx.lifecycle.viewmodel` unresolved | → `libs.androidx.lifecycle.viewmodelCompose` |
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte (nächste Session)
|
||||
|
||||
- [ ] Phase 4: QA & Testing des ZNS-Imports (🧐 QA Specialist)
|
||||
- [ ] `competition-context` Backend (👷 Backend Developer)
|
||||
- [ ] `event-management-context` Backend (👷 Backend Developer)
|
||||
236
docs/temp/Chat-Verlauf-Gemini_2026-03-25.md
Normal file
236
docs/temp/Chat-Verlauf-Gemini_2026-03-25.md
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
Das ist der Chat-Verlauf mit Gemini
|
||||
Datum: 25. März 2026
|
||||
|
||||
Dieser Verlauf begann mit den Dokumentationen
|
||||
|
||||
- docs/01_Architecture/MASTER_ROADMAP.md
|
||||
- docs/01_Architecture/Roadmap_ZNS_Importer.md
|
||||
|
||||
---
|
||||
|
||||
👷 [Backend Developer]
|
||||
|
||||
Großartig, packen wir es an! Da wir im actor-context mit Dateien arbeiten, die das Encoding CP850 (Codepage 850) nutzen
|
||||
und feste Spaltenbreiten (Fixed-Width) haben, bauen wir uns als Erstes ein robustes Parser-Werkzeug.
|
||||
|
||||
Da CP850 ein Single-Byte-Encoding ist, entspricht ein Byte exakt einem Zeichen (Char). Das macht das Parsing in Kotlin
|
||||
relativ einfach: Wenn wir die Datei mit dem richtigen Charset als Text einlesen, können wir mit einfachen
|
||||
substring-Operationen arbeiten. Da das Pflichtenheft 1-basierte Indizes verwendet (z.B. "Stelle 1"), bauen wir uns einen
|
||||
Reader, der genau diese Logik abbildet, um das Mapping so fehlerfrei wie möglich abtippen zu können.
|
||||
|
||||
Ich schlage vor, wir legen diesen Parser im core-utils Modul ab, da er eine reine Infrastruktur/Utility-Komponente ist.
|
||||
|
||||
Ich erstelle nun die Basis für den Parser.
|
||||
|
||||
---
|
||||
|
||||
👷 [Backend Developer]
|
||||
|
||||
Ich habe die Basis für das Parsing der ZNS-Dateien gelegt.
|
||||
Hier ist, was ich bisher gemacht habe:
|
||||
|
||||
1. FixedWidthLineReader: Eine Utility-Klasse in core-utils, die uns exakt nach den 1-basierten Spaltenangaben aus dem
|
||||
Pflichtenheft die Felder als String, Int oder Long ausschneidet.
|
||||
2. ZnsLegacyParsers: Ein dedizierter Parser, der pro Legacy-Datei (VEREIN01.DAT, LIZENZ01.DAT, PFERDE01.DAT,
|
||||
RICHT01.DAT) Zeile für Zeile in unsere modernen Domänenmodelle (DomVerein, DomReiter, DomPferd, DomFunktionaer)
|
||||
übersetzt. Sonderfälle wie das "Sperrlisten"-Flag und Enums (Reiterlizenz, Geschlecht) werden dort direkt gemappt.
|
||||
3. Abhängigkeiten: Die build.gradle.kts in core-utils wurde angepasst, sodass sie die Domänen der anderen SCS kennt, und
|
||||
es wurden bereits Tests für alle Parser hinzugefügt, um sicherzustellen, dass das Substring-Mapping anhand der Längen
|
||||
perfekt funktioniert.
|
||||
|
||||
Nächster Schritt: ZnsImportService (Der Orchestrator)
|
||||
|
||||
Als Nächstes brauchen wir im actor-context (bzw. einem übergreifenden Modul oder direkt in der Infrastruktur für den
|
||||
Import) einen Service, der:
|
||||
|
||||
1. Ein hochgeladenes .zip (oder byte-array) entgegennimmt und im Memory / Temp-Verzeichnis entpackt.
|
||||
2. Die entpackten .dat-Dateien explizit mit Charset.forName("Cp850") einliest, um die Umlaute zu bewahren.
|
||||
3. Die Zeilen an unseren ZnsLegacyParsers übergibt.
|
||||
4. Die importierten Aggregate über die Repositories (Upsert-Logik) in der Datenbank persistiert – und zwar genau in der
|
||||
festgelegten Reihenfolge (Vereine -> Reiter -> Pferde -> Richter).
|
||||
|
||||
Wo wollen wir diesen zentralen ZnsImportService ablegen? Er berührt technisch mehrere Bounded Contexts (Vereine,
|
||||
Reiter/Personen, Pferde, Funktionäre).
|
||||
|
||||
Sollen wir dafür ein eigenes kleines Infrastruktur-Modul backend:infrastructure:zns-import erstellen oder es im
|
||||
API-Gateway / einem dedizierten masterdata-Service bündeln? Was schlägt der Lead Architect vor?
|
||||
|
||||
---
|
||||
|
||||
Frage:
|
||||
Du willst in
|
||||
core/core-utils/src/commonMain/kotlin/at/mocode/core/utils/parser/ZnsLegacyParsers.kt
|
||||
=== Unresolved reference ===
|
||||
import at.mocode.clubs.domain.model.DomVerein
|
||||
import at.mocode.persons.domain.model.DomReiter
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.officials.domain.model.DomFunktionaer
|
||||
===
|
||||
importieren, aber diese sind "unresolved".
|
||||
|
||||
Wir haben diese Domänen in:
|
||||
|
||||
backend/services/clubs
|
||||
backend/services/horses
|
||||
backend/services/officials
|
||||
backend/services/persons
|
||||
|
||||
sind diese dort falsch platziert? Sollten diese nicht in "core/core-domain" sein?
|
||||
|
||||
Analysiere dies und erkläre es mir bitte.
|
||||
|
||||
---
|
||||
|
||||
🏗️ [Lead Architect]
|
||||
|
||||
Adlerauge! Sehr gut aufgepasst. Du hast hier intuitiv ein klassisches Architektur-Problem aufgedeckt – eine Verletzung
|
||||
der Dependency Rule (Schichtenarchitektur).
|
||||
|
||||
Lass mich das genau analysieren und erklären:
|
||||
|
||||
1. Sind die Domänen falsch platziert?
|
||||
|
||||
NEIN, sie sind absolut richtig platziert! Gemäß unserem ADR-0014 (Bounded Context Mapping) haben wir uns für eine
|
||||
Self-Contained-Systems (SCS) Architektur entschieden. Das bedeutet: Jeder Service (Clubs, Horses, Persons, Officials)
|
||||
ist fachlich autark und hat seine eigenen, isolierten Geschäftsregeln. Wenn wir DomVerein, DomReiter und DomPferd in das
|
||||
core-domain-Modul verschieben würden, würden wir wieder einen zentralen "Daten-Monolithen" erschaffen. Das core-domain
|
||||
Modul ist ausschließlich als Shared Kernel gedacht (für Dinge, die wirklich von allen geteilt werden, wie unsere
|
||||
ÖTO-Enums SparteE, Fehlercodes oder Basis-Klassen).
|
||||
|
||||
2. Warum schlägt der Import fehl?
|
||||
|
||||
Das Problem liegt nicht bei den Domänen, sondern beim ZnsLegacyParsers. Das Modul core-utils ist das absolute Fundament
|
||||
unserer Anwendung. Es liegt auf der tiefsten Ebene und wird von fast allen anderen Modulen konsumiert. Wenn wir in
|
||||
core-utils nun anfangen, auf backend/services/... zuzugreifen, erzeugen wir eine Rückwärts-Abhängigkeit. Das Fundament
|
||||
versucht auf einmal, das Dach zu kennen. Im schlimmsten Fall führt das in Gradle zu einer Circular Dependency (
|
||||
Kreisabhängigkeit).
|
||||
|
||||
Deswegen meckert die IDE und findet die Referenzen nicht – das Build-System verbietet (zurecht) diesen Zugriff von unten
|
||||
nach oben.
|
||||
|
||||
3. Wie ist die korrekte Architektur-Lösung?
|
||||
|
||||
Wir müssen Infrastruktur-Code (Utilities) von Fach-Code (ZNS-Domänen-Mapping) trennen:
|
||||
|
||||
• Der FixedWidthLineReader: Dieser bleibt in core-utils. Er ist ein rein technisches, dummes Werkzeug, das Strings
|
||||
anhand von Spaltenlängen zerschneidet. Er kennt keine Pferde oder Vereine.
|
||||
• Der ZnsLegacyParsers: Dieser muss aus core-utils gelöscht werden! Er gehört in eine Schicht, die weiter "oben" liegt
|
||||
und die Domänen-Modelle kennen darf.
|
||||
|
||||
Wohin mit dem Parser und dem Import-Service?
|
||||
|
||||
Da der ZNS-Import übergreifend arbeitet und vier verschiedene Bounded Contexts orchestrieren muss (Vereine -> Reiter ->
|
||||
Pferde -> Funktionäre), ist er ein klassischer Use-Case / Orchestrator.
|
||||
|
||||
Wir haben zwei architektonisch saubere Optionen:
|
||||
|
||||
Option A (Dezentral - im jeweiligen Context): Wir legen den Parser für DomVerein in
|
||||
backend/services/clubs/clubs-infrastructure ab, den für DomPferd in horses-infrastructure etc. Jeder Context baut sich
|
||||
seinen Legacy-Importer selbst. Ein übergeordneter Orchestrator ruft diese nur der Reihe nach auf.
|
||||
|
||||
Option B (Zentrales Integrations-Modul): Wir erstellen (oder nutzen das bestehende masterdata-Verzeichnis als) ein
|
||||
dediziertes Modul backend:infrastructure:zns-import (oder backend:services:zns-importer). Dieses Modul fungiert als
|
||||
Anti-Corruption Layer (ACL). Es hat Abhängigkeiten zu clubs-domain, persons-domain, horses-domain und übersetzt die
|
||||
externe Legacy-Welt zentral in unsere neuen Modelle und speichert sie über die jeweiligen Repositories.
|
||||
|
||||
Mein Architekturbeschluss: Wir wählen Option B als dediziertes Modul, da der Import eine einmalige/regelmäßige
|
||||
Massen-Infrastrukturaufgabe ist, die eng gekoppelt an eine externe Datei (.zip) ist. Wenn sich das ZNS-Format ändert,
|
||||
müssen wir nur diesen einen Importer anpassen und nicht vier verschiedene Services anfassen.
|
||||
|
||||
Nächste Schritte
|
||||
|
||||
Soll der 👷 Backend Developer:
|
||||
|
||||
1. Die fehlerhaften Domänen-Imports aus der core/core-utils/build.gradle.kts wieder entfernen?
|
||||
2. Die Datei ZnsLegacyParsers.kt aus core-utils löschen?
|
||||
3. Ein neues Modul backend/infrastructure/zns-importer anlegen, in dem wir den Orchestrator und die Parser sauber
|
||||
bündeln?
|
||||
|
||||
---
|
||||
|
||||
Ja ich bin ebenfalls deiner Meinung mit Option B (Zentrales Integrations-Modul) mit einem neuen Modul
|
||||
backend/infrastructure/zns-importer und wenn ich unsere Konstruktion richtig verstanden habe, können wir diesen "
|
||||
ZNS-Importer" dann auch in unsere Desktop-App integrieren, so dass wir auch echt offline mit der Desktop-App arbeiten
|
||||
können.
|
||||
Habe ich das richtig verstanden?
|
||||
|
||||
---
|
||||
|
||||
🏗️ [Lead Architect]
|
||||
|
||||
Exakt! Du hast den Nagel auf den Kopf getroffen. Das ist der absolute Königsvorteil unserer sauberen Trennung.
|
||||
|
||||
Wenn wir den ZNS-Importer so bauen, dass die reine Parsing- und Mapping-Logik (die Übersetzung von CP850-Strings in
|
||||
DomVerein, DomReiter etc.) keine Abhängigkeiten zu Spring Boot oder einer bestimmten Datenbank hat, können wir diese
|
||||
Logik direkt in der Compose Desktop App ausführen!
|
||||
Das ermöglicht folgenden genialen Offline-First Workflow:
|
||||
|
||||
1. Der Turnierveranstalter ist im Zelt am Reitplatz (kein Internet).
|
||||
2. Er lädt die ZNS.zip (die er vorher per USB-Stick oder Mail bekommen hat) in die Desktop-App.
|
||||
3. Die App entpackt und parst die Daten lokal auf dem Rechner.
|
||||
4. Die Daten werden in die lokale SQLite-Datenbank der Desktop-App (via SQLDelight) geschrieben.
|
||||
5. Sobald wieder Internet da ist, synchronisiert die App die neuen Stammdaten im Hintergrund mit unserem zentralen "
|
||||
Zora"-Backend.
|
||||
|
||||
Kleine Architektur-Korrektur für diesen Offline-Traum: Da die Compose Desktop App (Frontend) den Parser nutzen soll,
|
||||
darf der Parser selbst nicht im backend-Ordner liegen (das Frontend darf nicht vom Backend abhängen).
|
||||
|
||||
Ich habe die Dateien soeben aufgeräumt (die fehlerhaften Imports aus core-utils entfernt).
|
||||
|
||||
Um deinen Offline-Wunsch perfekt umzusetzen, schlage ich vor, wir bauen ein eigenes KMP-Modul namens core:zns-parser.
|
||||
Dieses Modul kennt die Domänen (clubs-domain, persons-domain, etc.) und enthält nur die reine Übersetzungslogik (
|
||||
String -> Domain Object).
|
||||
|
||||
• Das Backend nutzt dieses Modul, wenn jemand die Datei über die REST-API hochlädt.
|
||||
• Das Frontend (Desktop) nutzt dieses Modul, wenn jemand die Datei offline über das UI importiert.
|
||||
|
||||
Die eigentliche Speicher-Logik (das Upsert in die PostgreSQL via Repositories) bleibt im Backend (backend:
|
||||
infrastructure:zns-importer), während das Frontend seine eigene Speicher-Logik für SQLite baut.
|
||||
|
||||
Bist du mit diesem Split einverstanden? Dann würde der 👷 Backend Developer jetzt das Modul core:zns-parser aufsetzen und
|
||||
die reparierten Parser dort ablegen!
|
||||
|
||||
---
|
||||
|
||||
Perfekt, dass machen wir
|
||||
|
||||
---
|
||||
|
||||
👷 [Backend Developer]
|
||||
|
||||
Erledigt! Das war ein exzellenter architektonischer Eingriff. Ich habe die Struktur wie besprochen umgebaut:
|
||||
|
||||
1. core:zns-parser (Neues KMP-Modul):
|
||||
◦ Beinhaltet jetzt exklusiv ZnsLegacyParsers und die dazugehörigen Tests.
|
||||
◦ Kennt unsere Domänen-Modelle (DomVerein, DomReiter, etc.) und das core-utils Modul (für den FixedWidthLineReader).
|
||||
◦ Keine Abhängigkeiten zu Spring Boot oder Datenbanken. Die Desktop-App kann dieses Modul problemlos für den
|
||||
Offline-Import einbinden!
|
||||
2. core-utils (Bereinigt):
|
||||
◦ Die unerlaubten Rückwärts-Abhängigkeiten zu den Domänen wurden entfernt. Es enthält nur noch das rein technische
|
||||
Werkzeug (FixedWidthLineReader).
|
||||
3. backend:infrastructure:zns-importer (Neues Backend-Modul):
|
||||
◦ In der settings.gradle.kts registriert.
|
||||
◦ Dieses Modul kennt den core:zns-parser, alle Domänen und wird später Spring Boot kennen. Hier bauen wir den
|
||||
Orchestrator (Service), der die Daten nach dem Parsen in die Repositories schiebt.
|
||||
|
||||
Wie geht es weiter? Wir sind jetzt genau an dem Punkt, an dem wir den ZnsImportService in backend:infrastructure:
|
||||
zns-importer schreiben können.
|
||||
Dieser Service wird:
|
||||
|
||||
1. Ein InputStream oder ByteArray der .zip-Datei entgegennehmen.
|
||||
2. Die Zip-Datei entpacken (In-Memory).
|
||||
3. Die Dateien in der strikten Reihenfolge (VEREIN01.dat -> LIZENZ01.dat -> PFERDE01.dat -> RICHT01.dat) zeilenweise mit
|
||||
CP850 einlesen.
|
||||
4. Den ZnsLegacyParsers aufrufen.
|
||||
5. Die resultierenden Domänen-Objekte an die jeweiligen (noch zu definierenden/mockenden) Repositories (
|
||||
VereinRepository, ReiterRepository, etc.) zum Speichern/Upsert übergeben.
|
||||
|
||||
Soll ich mit der Implementierung dieses ZnsImportService starten?
|
||||
|
||||
---
|
||||
|
||||
Bitte überprüfe noch einmal deine letzte Arbeit, ich glaube dir ist dabei ein Fehler unterlaufen.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ kotlin {
|
|||
commonMain.dependencies {
|
||||
// Depend on core domain for User/Role types used by navigation API
|
||||
implementation(projects.frontend.core.domain)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
|
|
|
|||
|
|
@ -28,8 +28,13 @@ sealed class AppScreen(val route: String) {
|
|||
data object Funktionaere : AppScreen("/funktionaere")
|
||||
data object Meisterschaften : AppScreen("/meisterschaften")
|
||||
data object Cups : AppScreen("/cups")
|
||||
data object StammdatenImport : AppScreen("/stammdaten/import")
|
||||
|
||||
companion object {
|
||||
private val VERANSTALTUNG_DETAIL = Regex("/veranstaltung/(\\d+)$")
|
||||
private val TURNIER_DETAIL = Regex("/veranstaltung/(\\d+)/turnier/(\\d+)$")
|
||||
private val TURNIER_NEU = Regex("/veranstaltung/(\\d+)/turnier/neu$")
|
||||
|
||||
fun fromRoute(route: String): AppScreen {
|
||||
return when (route) {
|
||||
Routes.HOME -> Landing
|
||||
|
|
@ -49,7 +54,19 @@ sealed class AppScreen(val route: String) {
|
|||
"/funktionaere" -> Funktionaere
|
||||
"/meisterschaften" -> Meisterschaften
|
||||
"/cups" -> Cups
|
||||
else -> Landing // Default fallback
|
||||
"/stammdaten/import" -> StammdatenImport
|
||||
else -> {
|
||||
TURNIER_DETAIL.matchEntire(route)?.destructured?.let { (vId, tId) ->
|
||||
return TurnierDetail(vId.toLong(), tId.toLong())
|
||||
}
|
||||
TURNIER_NEU.matchEntire(route)?.destructured?.let { (vId) ->
|
||||
return TurnierNeu(vId.toLong())
|
||||
}
|
||||
VERANSTALTUNG_DETAIL.matchEntire(route)?.destructured?.let { (id) ->
|
||||
return VeranstaltungDetail(id.toLong())
|
||||
}
|
||||
Landing // Default fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
package at.mocode.frontend.core.navigation
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Minimal navigation abstraction used by core navigation components.
|
||||
* The actual implementation lives in shells/apps and delegates to the app's router.
|
||||
* Navigations-Abstraktion für alle Shells.
|
||||
* Die konkrete Implementierung liegt in der jeweiligen Shell (z.B. DesktopNavigationPort).
|
||||
*/
|
||||
interface NavigationPort {
|
||||
/** Aktuell angezeigter Screen als reaktiver State. */
|
||||
val currentScreen: StateFlow<AppScreen>
|
||||
|
||||
/** Navigation via Route-String (z.B. für Deep-Links). */
|
||||
fun navigateTo(route: String)
|
||||
|
||||
/** Typsichere Navigation direkt via AppScreen-Objekt. */
|
||||
fun navigateToScreen(screen: AppScreen)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,20 @@ import org.koin.core.qualifier.named
|
|||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* Simple token provider interface so the core network module does not depend on auth-feature.
|
||||
* Schnittstelle zur Token-Bereitstellung – entkoppelt core-network von core-auth.
|
||||
*/
|
||||
interface TokenProvider {
|
||||
fun getAccessToken(): String?
|
||||
}
|
||||
|
||||
/**
|
||||
* Koin module providing HttpClients.
|
||||
* Koin-Modul mit zwei HttpClient-Instanzen:
|
||||
* - "baseHttpClient": Roh-Client für Auth/Keycloak (kein Token-Header)
|
||||
* - "apiClient": Konfigurierter Client für das API-Gateway (Auth-Header, Retry, Timeout)
|
||||
*/
|
||||
val networkModule = module {
|
||||
|
||||
// 1. Base Client (Raw, for Auth/Keycloak)
|
||||
// 1. Basis-Client (für Auth-Endpunkte, ohne Bearer-Token)
|
||||
single(named("baseHttpClient")) {
|
||||
HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
|
|
@ -39,48 +41,27 @@ val networkModule = module {
|
|||
}
|
||||
}
|
||||
|
||||
// 2. API Client (Configured for Gateway & Auth Header)
|
||||
// 2. API-Client (Gateway, mit Bearer-Token, Retry & Timeout)
|
||||
single(named("apiClient")) {
|
||||
// Resolve TokenProvider lazily to avoid circular dependency issues during init
|
||||
// We use a provider lambda to get the TokenProvider instance when needed
|
||||
// This avoids resolving it immediately during module definition
|
||||
val koinScope = this@single
|
||||
|
||||
HttpClient {
|
||||
// JSON (kotlinx) configuration
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
)
|
||||
json(Json { ignoreUnknownKeys = true; isLenient = true; encodeDefaults = true })
|
||||
}
|
||||
|
||||
// Request timeouts
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 15_000
|
||||
connectTimeoutMillis = 10_000
|
||||
socketTimeoutMillis = 15_000
|
||||
}
|
||||
|
||||
// Automatic simple retry on network exceptions and 5xx
|
||||
install(HttpRequestRetry) {
|
||||
maxRetries = 3
|
||||
retryIf { _, response ->
|
||||
val s = response.status.value
|
||||
s == 0 || s >= 500
|
||||
}
|
||||
retryIf { _, response -> response.status.value.let { it == 0 || it >= 500 } }
|
||||
exponentialDelay()
|
||||
}
|
||||
|
||||
// Base URL configuration
|
||||
defaultRequest {
|
||||
val base = NetworkConfig.baseUrl.trimEnd('/')
|
||||
url(base)
|
||||
url(NetworkConfig.baseUrl.trimEnd('/'))
|
||||
}
|
||||
|
||||
// Logging for development
|
||||
install(Logging) {
|
||||
logger = object : Logger {
|
||||
override fun log(message: String) {
|
||||
|
|
@ -90,79 +71,13 @@ val networkModule = module {
|
|||
level = LogLevel.INFO
|
||||
}
|
||||
}.also { client ->
|
||||
// Dynamic Auth Header Injection via HttpSend plugin
|
||||
// This ensures we get the CURRENT token for each request
|
||||
// Bearer-Token pro Request dynamisch injizieren (lazy, damit kein Circular-Dependency)
|
||||
client.plugin(HttpSend).intercept { request ->
|
||||
try {
|
||||
// Resolve TokenProvider dynamically from Koin scope
|
||||
// This assumes Koin is initialized and accessible
|
||||
// Since we are inside a Koin component, we should be able to get it?
|
||||
// No, 'this' here is HttpSendScope.
|
||||
|
||||
// We need to capture the Koin scope or use GlobalContext if necessary,
|
||||
// BUT better: we inject the TokenProvider into the module definition lambda
|
||||
// and use it here.
|
||||
|
||||
// However, `get<TokenProvider>()` might fail if not yet registered.
|
||||
// Let's try to resolve it safely.
|
||||
|
||||
// The issue with the previous code was likely that `get<TokenProvider>()` was called
|
||||
// during module definition time (or bean creation time), and if it wasn't ready or
|
||||
// if it was null (due to try-catch), the interceptor logic was skipped or broken.
|
||||
|
||||
// Let's try to get it from the Koin instance that created this client.
|
||||
// But we are inside `single { ... }`.
|
||||
|
||||
// We can capture the `Scope` from the `single` block.
|
||||
// val scope = this // Koin Scope
|
||||
|
||||
// But we can't easily pass `scope` into `intercept`.
|
||||
|
||||
// Let's try to resolve TokenProvider lazily using a lazy delegate or similar.
|
||||
// Or just resolve it inside the interceptor if we can access Koin.
|
||||
|
||||
// Since we are in `single`, we can get the provider.
|
||||
// The previous error `TypeError: this.getToken_wiq2bn_k$ is not a function`
|
||||
// was in AuthModule, which we fixed.
|
||||
|
||||
// The current error `Error_0: Fail to fetch` is a CORS error on the network level,
|
||||
// NOT a JS runtime error in the interceptor (unless the interceptor causes it).
|
||||
|
||||
// Wait, the logs show:
|
||||
// [baseClient] REQUEST: .../token
|
||||
// Access to fetch at ... blocked by CORS policy
|
||||
|
||||
// This confirms it is a CORS issue on the Keycloak server side or the browser side.
|
||||
// The JS error `TypeError` is GONE in the latest log!
|
||||
|
||||
// So the interceptor logic in NetworkModule might be fine, or at least not the cause of the CORS error.
|
||||
// But let's make it robust anyway.
|
||||
|
||||
// We will use a safe lazy resolution pattern.
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
execute(request)
|
||||
}
|
||||
|
||||
// Re-applying the logic with proper Koin resolution
|
||||
val koinScope = this@single
|
||||
|
||||
client.plugin(HttpSend).intercept { request ->
|
||||
try {
|
||||
// Attempt to resolve TokenProvider from the capturing scope
|
||||
val tokenProvider = try {
|
||||
koinScope.get<TokenProvider>()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
val token = tokenProvider?.getAccessToken()
|
||||
if (token != null) {
|
||||
request.header("Authorization", "Bearer $token")
|
||||
}
|
||||
val token = koinScope.get<TokenProvider>().getAccessToken()
|
||||
if (token != null) request.header("Authorization", "Bearer $token")
|
||||
} catch (e: Exception) {
|
||||
println("[apiClient] Error injecting auth header: $e")
|
||||
println("[apiClient] Auth-Header konnte nicht gesetzt werden: $e")
|
||||
}
|
||||
execute(request)
|
||||
}
|
||||
|
|
|
|||
28
frontend/features/zns-import-feature/build.gradle.kts
Normal file
28
frontend/features/zns-import-feature/build.gradle.kts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Feature-Modul: ZNS-Stammdaten-Import (Desktop-only)
|
||||
* Kapselt ViewModel, State und API-Kommunikation für den ZNS-Import.
|
||||
*/
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
group = "at.mocode.clients"
|
||||
version = "1.0.0"
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
sourceSets {
|
||||
jvmMain.dependencies {
|
||||
implementation(projects.frontend.core.network)
|
||||
implementation(projects.frontend.core.auth)
|
||||
implementation(libs.bundles.kmp.common)
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.contentNegotiation)
|
||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
package at.mocode.zns.feature
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
import at.mocode.frontend.core.network.NetworkConfig
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
data class ZnsImportState(
|
||||
val selectedFilePath: String? = null,
|
||||
val isUploading: Boolean = false,
|
||||
val jobId: String? = null,
|
||||
val jobStatus: String? = null,
|
||||
val progress: Int = 0,
|
||||
val progressDetail: String = "",
|
||||
val errors: List<String> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val isFinished: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class JobIdResponse(val jobId: String)
|
||||
|
||||
@Serializable
|
||||
internal data class JobStatusResponse(
|
||||
val jobId: String,
|
||||
val status: String,
|
||||
val progress: Int = 0,
|
||||
val progressDetail: String = "",
|
||||
val errors: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
private val TERMINAL_STATES = setOf("ABGESCHLOSSEN", "FEHLER")
|
||||
private const val POLLING_INTERVAL_MS = 2000L
|
||||
private const val MAX_VISIBLE_ERRORS = 50
|
||||
|
||||
class ZnsImportViewModel(
|
||||
private val httpClient: HttpClient,
|
||||
private val authTokenManager: AuthTokenManager,
|
||||
) : ViewModel() {
|
||||
|
||||
var state by mutableStateOf(ZnsImportState())
|
||||
private set
|
||||
|
||||
private var pollingJob: Job? = null
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun onFileSelected(path: String) {
|
||||
state = ZnsImportState(selectedFilePath = path)
|
||||
}
|
||||
|
||||
fun startImport() {
|
||||
val filePath = state.selectedFilePath ?: return
|
||||
val file = File(filePath)
|
||||
if (!file.exists() || !file.name.endsWith(".zip", ignoreCase = true)) {
|
||||
state = state.copy(errorMessage = "Bitte eine gültige .zip-Datei auswählen.")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
state = state.copy(
|
||||
isUploading = true, errorMessage = null, isFinished = false,
|
||||
jobId = null, progress = 0, progressDetail = "", errors = emptyList()
|
||||
)
|
||||
try {
|
||||
val token = authTokenManager.authState.value.token
|
||||
val response: HttpResponse = httpClient.post("${NetworkConfig.baseUrl}/api/v1/import/zns") {
|
||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||
setBody(MultiPartFormDataContent(formData {
|
||||
append("file", file.readBytes(), Headers.build {
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
|
||||
append(HttpHeaders.ContentType, "application/zip")
|
||||
})
|
||||
}))
|
||||
}
|
||||
if (response.status == HttpStatusCode.Accepted) {
|
||||
val body = json.decodeFromString<JobIdResponse>(response.bodyAsText())
|
||||
state = state.copy(isUploading = false, jobId = body.jobId, jobStatus = "AUSSTEHEND")
|
||||
startPolling(body.jobId)
|
||||
} else {
|
||||
state = state.copy(isUploading = false, errorMessage = "Upload fehlgeschlagen: HTTP ${response.status.value}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state = state.copy(isUploading = false, errorMessage = "Fehler beim Upload: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPolling(jobId: String) {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
try {
|
||||
val token = authTokenManager.authState.value.token
|
||||
val response: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/import/zns/$jobId/status") {
|
||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
val status = json.decodeFromString<JobStatusResponse>(response.bodyAsText())
|
||||
state = state.copy(
|
||||
jobStatus = status.status,
|
||||
progress = status.progress,
|
||||
progressDetail = status.progressDetail,
|
||||
errors = status.errors.takeLast(MAX_VISIBLE_ERRORS),
|
||||
isFinished = status.status in TERMINAL_STATES,
|
||||
)
|
||||
if (status.status in TERMINAL_STATES) break
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state = state.copy(errorMessage = "Polling-Fehler: ${e.message}", isFinished = true)
|
||||
break
|
||||
}
|
||||
delay(POLLING_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
pollingJob?.cancel()
|
||||
state = ZnsImportState()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
pollingJob?.cancel()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package at.mocode.zns.feature.di
|
||||
|
||||
import at.mocode.zns.feature.ZnsImportViewModel
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val znsImportModule = module {
|
||||
factory { ZnsImportViewModel(get(named("apiClient")), get()) }
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ kotlin {
|
|||
// Feature-Module
|
||||
implementation(projects.frontend.features.nennungFeature)
|
||||
implementation(projects.frontend.features.pingFeature)
|
||||
implementation(projects.frontend.features.znsImportFeature)
|
||||
|
||||
// Compose Desktop
|
||||
implementation(compose.desktop.currentOs)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import at.mocode.frontend.core.network.networkModule
|
|||
import at.mocode.frontend.core.sync.di.syncModule
|
||||
import at.mocode.nennung.feature.di.nennungFeatureModule
|
||||
import at.mocode.ping.feature.di.pingFeatureModule
|
||||
import at.mocode.zns.feature.di.znsImportModule
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.context.loadKoinModules
|
||||
|
|
@ -29,6 +30,7 @@ fun main() = application {
|
|||
localDbModule,
|
||||
pingFeatureModule,
|
||||
nennungFeatureModule,
|
||||
znsImportModule,
|
||||
desktopModule,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
*/
|
||||
class DesktopNavigationPort : NavigationPort {
|
||||
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Login())
|
||||
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
|
||||
override val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
|
||||
|
||||
override fun navigateTo(route: String) {
|
||||
val screen = AppScreen.fromRoute(route)
|
||||
|
|
@ -20,7 +20,7 @@ class DesktopNavigationPort : NavigationPort {
|
|||
_currentScreen.value = screen
|
||||
}
|
||||
|
||||
fun navigateToScreen(screen: AppScreen) {
|
||||
override fun navigateToScreen(screen: AppScreen) {
|
||||
println("[DesktopNav] navigateToScreen -> $screen")
|
||||
_currentScreen.value = screen
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ private val navItems = listOf(
|
|||
NavItem("Funktionäre", Icons.Default.Badge, AppScreen.Funktionaere),
|
||||
NavItem("Meisterschaften", Icons.Default.EmojiEvents, AppScreen.Meisterschaften),
|
||||
NavItem("Cups", Icons.Default.WorkspacePremium, AppScreen.Cups),
|
||||
NavItem("Stammdaten-Import", Icons.Default.CloudUpload, AppScreen.StammdatenImport),
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -221,6 +222,7 @@ private fun DesktopContentArea(
|
|||
is AppScreen.Funktionaere -> FunktionaereScreen()
|
||||
is AppScreen.Meisterschaften -> MeisterschaftenScreen()
|
||||
is AppScreen.Cups -> CupsScreen()
|
||||
is AppScreen.StammdatenImport -> StammdatenImportScreen()
|
||||
// Fallback für alle anderen Screens (Dashboard, Ping etc.)
|
||||
else -> VeranstaltungenScreen(
|
||||
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
package at.mocode.desktop.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.zns.feature.ZnsImportViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
|
||||
@Composable
|
||||
fun StammdatenImportScreen(
|
||||
viewModel: ZnsImportViewModel = koinViewModel(),
|
||||
) {
|
||||
val state = viewModel.state
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Titel
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.CloudUpload, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Text("Stammdaten-Import (ZNS)", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Datei-Auswahl
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Datei auswählen", style = MaterialTheme.typography.titleMedium)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.selectedFilePath ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
placeholder = { Text("Keine Datei ausgewählt…") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val path = pickZipFile()
|
||||
if (path != null) viewModel.onFileSelected(path)
|
||||
},
|
||||
enabled = !state.isUploading && !(!state.isFinished && state.jobId != null),
|
||||
) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Durchsuchen")
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { viewModel.startImport() },
|
||||
enabled = state.selectedFilePath != null && !state.isUploading && !(state.jobId != null && !state.isFinished),
|
||||
) {
|
||||
if (state.isUploading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
} else {
|
||||
Icon(Icons.Default.Upload, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text("Import starten")
|
||||
}
|
||||
if (state.isFinished || state.errorMessage != null) {
|
||||
OutlinedButton(onClick = { viewModel.reset() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Zurücksetzen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fehler-Banner
|
||||
if (state.errorMessage != null) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
|
||||
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fortschritt
|
||||
if (state.jobId != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Status", style = MaterialTheme.typography.titleMedium)
|
||||
StatusChip(state.jobStatus)
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { state.progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
state.progressDetail.ifBlank { "Warte auf Server…" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"${state.progress}%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isFinished && state.jobStatus == "ABGESCHLOSSEN") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
"Import erfolgreich abgeschlossen.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fehler-Liste
|
||||
if (state.errors.isNotEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
"Import-Fehler (${state.errors.size})",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(state.errors) { error ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text("•", color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusChip(status: String?) {
|
||||
val (label, color) = when (status) {
|
||||
"AUSSTEHEND" -> "Ausstehend" to MaterialTheme.colorScheme.outline
|
||||
"ENTPACKEN" -> "Entpacken…" to MaterialTheme.colorScheme.tertiary
|
||||
"LADE_VEREINE" -> "Lade Vereine…" to MaterialTheme.colorScheme.secondary
|
||||
"LADE_REITER" -> "Lade Reiter…" to MaterialTheme.colorScheme.secondary
|
||||
"LADE_PFERDE" -> "Lade Pferde…" to MaterialTheme.colorScheme.secondary
|
||||
"LADE_RICHTER" -> "Lade Richter…" to MaterialTheme.colorScheme.secondary
|
||||
"ABGESCHLOSSEN" -> "Abgeschlossen ✓" to MaterialTheme.colorScheme.primary
|
||||
"FEHLER" -> "Fehler ✗" to MaterialTheme.colorScheme.error
|
||||
else -> (status ?: "–") to MaterialTheme.colorScheme.outline
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = color.copy(alpha = 0.15f),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Öffnet einen nativen JFileChooser (JVM-only) und gibt den Pfad der gewählten ZIP zurück. */
|
||||
private fun pickZipFile(): String? {
|
||||
val chooser = JFileChooser()
|
||||
chooser.dialogTitle = "ZNS.zip auswählen"
|
||||
chooser.fileFilter = FileNameExtensionFilter("ZIP-Archiv (*.zip)", "zip")
|
||||
chooser.isAcceptAllFileFilterUsed = false
|
||||
val result = chooser.showOpenDialog(null)
|
||||
return if (result == JFileChooser.APPROVE_OPTION) chooser.selectedFile.absolutePath else null
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
@file:OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
|
||||
|
||||
/**
|
||||
* Dieses Modul ist der "Host". Es kennt alle Features und die Shared-Module und
|
||||
* setzt sie zu einer lauffähigen Anwendung zusammen.
|
||||
*/
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
// JVM Target für Desktop
|
||||
jvm {
|
||||
binaries {
|
||||
executable {
|
||||
mainClass.set("MainKt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JavaScript Target für Web
|
||||
js {
|
||||
browser {
|
||||
|
||||
commonWebpackConfig {
|
||||
cssSupport { enabled = true }
|
||||
mode = if (project.hasProperty("production"))
|
||||
KotlinWebpackConfig.Mode.PRODUCTION
|
||||
else
|
||||
KotlinWebpackConfig.Mode.DEVELOPMENT
|
||||
|
||||
// Source Maps: Im Production-Mode standardmäßig AUS (außer explizit via -PenableSourceMaps).
|
||||
// Beschleunigt den Build massiv und reduziert Bundle-Größe.
|
||||
if (mode == KotlinWebpackConfig.Mode.PRODUCTION && !project.hasProperty("enableSourceMaps")) {
|
||||
sourceMaps = false
|
||||
}
|
||||
}
|
||||
|
||||
webpackTask {
|
||||
mainOutputFileName = "web-app.js"
|
||||
// Minification wird via webpack.config.d/z_disable-minification.js deaktiviert,
|
||||
// um den Terser-Crash mit SQLite-WASM (sqlite3-worker1.mjs) zu verhindern.
|
||||
// Siehe: webpack.config.d/z_disable-minification.js
|
||||
}
|
||||
|
||||
// Development Server konfigurieren
|
||||
runTask {
|
||||
mainOutputFileName.set("web-app.js")
|
||||
}
|
||||
}
|
||||
binaries.executable()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// Shared modules
|
||||
implementation(projects.frontend.core.domain)
|
||||
implementation(projects.frontend.core.designSystem)
|
||||
implementation(projects.frontend.core.navigation)
|
||||
implementation(projects.frontend.core.network)
|
||||
implementation(projects.frontend.core.sync)
|
||||
implementation(projects.frontend.core.localDb)
|
||||
implementation(projects.frontend.core.auth)
|
||||
implementation(projects.frontend.features.pingFeature)
|
||||
|
||||
// DI (Koin) needed to call initKoin { modules(...) }
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.koin.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
|
||||
// Compose Multiplatform
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.components.resources)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(compose.components.uiToolingPreview)
|
||||
|
||||
// Bundles
|
||||
implementation(libs.bundles.kmp.common) // Coroutines, Serialization, DateTime
|
||||
implementation(libs.bundles.compose.common) // ViewModel & Lifecycle
|
||||
}
|
||||
|
||||
jvmMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
implementation(compose.uiTooling)
|
||||
implementation(libs.koin.core)
|
||||
implementation(project(":frontend:features:nennung-feature"))
|
||||
}
|
||||
|
||||
jsMain.dependencies {
|
||||
implementation(compose.html.core)
|
||||
// Benötigt für custom webpack config (wasm.js)
|
||||
implementation(devNpm("copy-webpack-plugin", libs.versions.copyWebpackPlugin.get()))
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate-Handling für Distribution (Zentralisiert in Root build.gradle.kts, aber hier spezifisch für Distribution Tasks)
|
||||
tasks.withType<Tar> {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
tasks.withType<Zip> {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
// Desktop Application Configuration
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "MainKt"
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "Meldestelle"
|
||||
packageVersion = "1.0.0"
|
||||
description = "Meldestelle Development App"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
expect fun isDevelopmentMode(): Boolean
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
import at.mocode.frontend.core.auth.presentation.LoginScreen
|
||||
import at.mocode.frontend.core.auth.presentation.LoginViewModel
|
||||
import at.mocode.frontend.core.designsystem.theme.AppTheme
|
||||
import at.mocode.frontend.core.domain.PlatformType
|
||||
import at.mocode.frontend.core.domain.currentPlatform
|
||||
import at.mocode.frontend.core.navigation.AppScreen
|
||||
import at.mocode.ping.feature.presentation.PingScreen
|
||||
import at.mocode.ping.feature.presentation.PingViewModel
|
||||
import navigation.StateNavigationPort
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import screens.*
|
||||
|
||||
@Composable
|
||||
fun MainApp() {
|
||||
AppTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val navigationPort = koinInject<StateNavigationPort>()
|
||||
val currentScreen by navigationPort.currentScreen.collectAsState()
|
||||
val authTokenManager = koinInject<AuthTokenManager>()
|
||||
val pingViewModel: PingViewModel = koinViewModel()
|
||||
val loginViewModel: LoginViewModel = koinViewModel()
|
||||
|
||||
when (val screen = currentScreen) {
|
||||
is AppScreen.Landing -> {
|
||||
if (currentPlatform() == PlatformType.DESKTOP) {
|
||||
val authState = authTokenManager.authState.collectAsState().value
|
||||
if (authState.isAuthenticated) {
|
||||
DashboardScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onLogout = {
|
||||
authTokenManager.clearToken()
|
||||
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
|
||||
},
|
||||
onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) }
|
||||
)
|
||||
} else {
|
||||
LaunchedEffect(Unit) {
|
||||
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LandingScreen(
|
||||
onPrimaryCta = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) },
|
||||
onOpenPing = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.OrganizerProfile)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AppScreen.Dashboard -> DashboardScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onNennungOeffnen = { navigationPort.navigateToScreen(AppScreen.Nennung) },
|
||||
onLogout = {
|
||||
authTokenManager.clearToken()
|
||||
if (currentPlatform() == PlatformType.DESKTOP) {
|
||||
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
|
||||
} else {
|
||||
navigationPort.navigateToScreen(AppScreen.Landing)
|
||||
}
|
||||
},
|
||||
onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) }
|
||||
)
|
||||
|
||||
is AppScreen.Home -> DashboardScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onLogout = {
|
||||
authTokenManager.clearToken()
|
||||
if (currentPlatform() == PlatformType.DESKTOP) {
|
||||
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard))
|
||||
} else {
|
||||
navigationPort.navigateToScreen(AppScreen.Landing)
|
||||
}
|
||||
},
|
||||
onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) }
|
||||
)
|
||||
|
||||
is AppScreen.CreateTournament -> CreateTournamentScreen(
|
||||
onBack = { navigationPort.navigateToScreen(AppScreen.Dashboard) },
|
||||
onSave = { navigationPort.navigateToScreen(AppScreen.Dashboard) }
|
||||
)
|
||||
|
||||
is AppScreen.Login -> {
|
||||
LoginScreen(
|
||||
viewModel = loginViewModel,
|
||||
onLoginSuccess = {
|
||||
val returnTo = screen.returnTo
|
||||
if (returnTo != null) {
|
||||
navigationPort.navigateToScreen(returnTo)
|
||||
} else {
|
||||
navigationPort.navigateToScreen(AppScreen.Dashboard)
|
||||
}
|
||||
},
|
||||
onBack = {
|
||||
if (currentPlatform() == PlatformType.DESKTOP) {
|
||||
// Desktop hat keine Landing Page — bleibt auf Login
|
||||
} else {
|
||||
navigationPort.navigateToScreen(AppScreen.Landing)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is AppScreen.Ping -> PingScreen(
|
||||
viewModel = pingViewModel,
|
||||
onBack = {
|
||||
if (currentPlatform() == PlatformType.DESKTOP) {
|
||||
navigationPort.navigateToScreen(AppScreen.Dashboard)
|
||||
} else {
|
||||
navigationPort.navigateToScreen(AppScreen.Landing)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
is AppScreen.Nennung -> {
|
||||
// NennungsMaske wird über das nennung-feature eingebunden (jvmMain only)
|
||||
// Placeholder, bis das Feature vollständig integriert ist
|
||||
NennungScreenContent()
|
||||
}
|
||||
|
||||
is AppScreen.OrganizerProfile -> OrganizerProfileScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onLogout = {
|
||||
authTokenManager.clearToken()
|
||||
navigationPort.navigateToScreen(AppScreen.Landing)
|
||||
},
|
||||
onNavigateToDashboard = { navigationPort.navigateToScreen(AppScreen.Dashboard) }
|
||||
)
|
||||
|
||||
is AppScreen.AuthCallback -> { /* OIDC Callback wird vom Auth-Modul verarbeitet */
|
||||
}
|
||||
|
||||
is AppScreen.Profile -> AuthStatusScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onNavigateToLogin = {
|
||||
navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Profile))
|
||||
},
|
||||
onNavigateToPing = { navigationPort.navigateToScreen(AppScreen.Ping) },
|
||||
onBackToHome = {
|
||||
if (currentPlatform() == PlatformType.DESKTOP) {
|
||||
navigationPort.navigateToScreen(AppScreen.Dashboard)
|
||||
} else {
|
||||
navigationPort.navigateToScreen(AppScreen.Landing)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
expect fun NennungScreenContent()
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
package components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// Dummy-Datenklasse für Turnier-Einträge (wird später durch echtes Domain-Model ersetzt)
|
||||
data class TournamentData(
|
||||
val id: String,
|
||||
val date: String,
|
||||
val title: String,
|
||||
val location: String
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TournamentCard(data: TournamentData) {
|
||||
OutlinedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Left: Logo Placeholder
|
||||
Surface(
|
||||
modifier = Modifier.size(100.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"URFV\nLogo",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
||||
// Middle: Info
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = data.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${data.location} ${data.date}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Turnier-Nr.:${data.id}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
||||
// Right: Actions
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.width(200.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { /* TODO */ },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Ausschreibung")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { /* TODO */ },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Nennen")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { /* TODO */ },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Start- Ergebnislisten")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToggleRow(label: String, isOnline: Boolean, isInteractive: Boolean = false) {
|
||||
Surface(
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.fillMaxWidth().height(40.dp),
|
||||
color = if (isInteractive) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val statusColor = if (isOnline) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
|
||||
Surface(
|
||||
modifier = Modifier.size(16.dp),
|
||||
shape = CircleShape,
|
||||
color = statusColor
|
||||
) {}
|
||||
|
||||
if (isInteractive) {
|
||||
Surface(
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
modifier = Modifier.width(40.dp).height(24.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(if (isOnline) "on" else "off", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
if (isOnline) "Online" else "Offline",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.width(40.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
package navigation
|
||||
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
import at.mocode.frontend.core.domain.models.User
|
||||
import at.mocode.frontend.core.navigation.AppScreen
|
||||
import at.mocode.frontend.core.navigation.CurrentUserProvider
|
||||
import at.mocode.frontend.core.navigation.DeepLinkHandler
|
||||
import at.mocode.frontend.core.navigation.NavigationPort
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.koin.dsl.module
|
||||
|
||||
class ShellCurrentUserProvider(
|
||||
private val authTokenManager: AuthTokenManager,
|
||||
) : CurrentUserProvider {
|
||||
override fun getCurrentUser(): User? {
|
||||
val state = authTokenManager.authState.value
|
||||
if (!state.isAuthenticated) return null
|
||||
// Roles are not yet modeled in AuthState; provide an empty list for now
|
||||
return User(
|
||||
id = state.userId ?: state.username ?: "unknown",
|
||||
username = state.username ?: state.userId ?: "unknown",
|
||||
displayName = null,
|
||||
roles = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A real implementation of NavigationPort that updates a StateFlow.
|
||||
* This allows the MainApp to observe changes and update the UI.
|
||||
*/
|
||||
class StateNavigationPort : NavigationPort {
|
||||
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Landing)
|
||||
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
|
||||
|
||||
override fun navigateTo(route: String) {
|
||||
val screen = AppScreen.fromRoute(route)
|
||||
println("[NavigationPort] navigateTo $route -> $screen")
|
||||
_currentScreen.value = screen
|
||||
}
|
||||
|
||||
fun navigateToScreen(screen: AppScreen) {
|
||||
_currentScreen.value = screen
|
||||
}
|
||||
}
|
||||
|
||||
val navigationModule = module {
|
||||
single<CurrentUserProvider> { ShellCurrentUserProvider(get()) }
|
||||
// Bind as both NavigationPort (for Core) and StateNavigationPort (for Shell)
|
||||
single { StateNavigationPort() }
|
||||
single<NavigationPort> { get<StateNavigationPort>() }
|
||||
single { DeepLinkHandler(get(), get()) }
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
package screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
|
||||
@Composable
|
||||
fun AuthStatusScreen(
|
||||
authTokenManager: AuthTokenManager,
|
||||
onNavigateToLogin: () -> Unit,
|
||||
onNavigateToPing: () -> Unit,
|
||||
onBackToHome: () -> Unit
|
||||
) {
|
||||
val authState by authTokenManager.authState.collectAsState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("Ping-Service / System Status", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
if (authState.isAuthenticated) {
|
||||
Text(
|
||||
"Du bist angemeldet als: ${authState.username ?: authState.userId ?: "unbekannt"}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Button(onClick = onNavigateToPing) {
|
||||
Text("Ping-Service Tests durchführen")
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
authTokenManager.clearToken()
|
||||
}) { Text("Abmelden") }
|
||||
}
|
||||
|
||||
} else {
|
||||
Text(
|
||||
"Du bist abgemeldet.",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Button(onClick = onNavigateToLogin) {
|
||||
Text("Login")
|
||||
}
|
||||
OutlinedButton(onClick = onNavigateToPing) {
|
||||
Text("Ping-Service (eingeschränkt testen)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
OutlinedButton(onClick = onBackToHome) { Text("Zurück zur Startseite") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
package screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun CreateTournamentScreen(
|
||||
onBack: () -> Unit,
|
||||
onSave: () -> Unit
|
||||
) {
|
||||
var currentStep by remember { mutableStateOf(1) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// App Header
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("← Zurück")
|
||||
}
|
||||
Text(
|
||||
text = "Neues Turnier anlegen (Desktop Client)",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stepper / Progress Bar
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StepIndicator(step = 1, title = "Transfer", isActive = currentStep == 1, isCompleted = currentStep > 1)
|
||||
StepIndicator(step = 2, title = "Stammdaten", isActive = currentStep == 2, isCompleted = currentStep > 2)
|
||||
StepIndicator(step = 3, title = "Konfiguration", isActive = currentStep == 3, isCompleted = currentStep > 3)
|
||||
StepIndicator(step = 4, title = "Funktionäre", isActive = currentStep == 4, isCompleted = currentStep > 4)
|
||||
StepIndicator(step = 5, title = "Bewerbe", isActive = currentStep == 5, isCompleted = currentStep > 5)
|
||||
}
|
||||
}
|
||||
|
||||
// Wizard Content Area
|
||||
Box(modifier = Modifier.weight(1f).padding(24.dp)) {
|
||||
when (currentStep) {
|
||||
1 -> TournamentStepTransfer()
|
||||
2 -> TournamentStepStammdaten()
|
||||
3 -> TournamentStepKonfiguration()
|
||||
4 -> TournamentStepFunktionaere()
|
||||
5 -> TournamentStepBewerbe()
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom Navigation Bar
|
||||
Surface(shadowElevation = 8.dp, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (currentStep > 1) {
|
||||
OutlinedButton(onClick = { currentStep-- }) { Text("Zurück") }
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(1.dp))
|
||||
}
|
||||
|
||||
if (currentStep < 5) {
|
||||
Button(onClick = { currentStep++ }) { Text("Weiter") }
|
||||
} else {
|
||||
Button(onClick = onSave) { Text("Turnier speichern") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StepIndicator(step: Int, title: String, isActive: Boolean, isCompleted: Boolean) {
|
||||
val color = when {
|
||||
isActive -> MaterialTheme.colorScheme.primary
|
||||
isCompleted -> MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
|
||||
}
|
||||
val fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = color,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(step.toString(), color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
Text(title, color = color, fontWeight = fontWeight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TournamentStepTransfer() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.fillMaxWidth(0.8f)) {
|
||||
Text("Schritt 1: Transfer & Initialisierung", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"In diesem Schritt erschaffen wir eine separate Datenbank für dieses spezifische Turnier. " +
|
||||
"Diese Datenbank kann für den komplett isolierten Offline-Betrieb (z.B. am USB-Stick) auf andere Laptops übertragen werden.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
label = { Text("Turniernummer OEPS (z.B. 26128)") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Button(onClick = { /*TODO*/ }, modifier = Modifier.align(Alignment.CenterVertically)) {
|
||||
Text("Turnierdatenbank Initialisieren")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text("Datenaustausch (OEPS / Externe Systeme)", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Card(modifier = Modifier.weight(1f)) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("ZNS / Stammdaten Import", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Aktualisieren Sie die Reiter, Pferde und Funktionäre aus dem zentralen System.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
label = { Text("Pfad zur ZNS.zip") },
|
||||
modifier = Modifier.weight(1f),
|
||||
readOnly = true
|
||||
)
|
||||
Button(onClick = { /* Öffnet File Picker */ }) { Text("...") }
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
label = { Text("Pfad zur AWÖ/Zucht-Datei") },
|
||||
modifier = Modifier.weight(1f),
|
||||
readOnly = true
|
||||
)
|
||||
Button(onClick = { /* Öffnet File Picker */ }) { Text("...") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.weight(1f)) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("OEPS Export", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Erzeugt die xxxxx.erg Datei für die offizielle Ergebnismeldung nach dem Turnier.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
label = { Text("Ziel-Ordner für .erg Export") },
|
||||
modifier = Modifier.weight(1f),
|
||||
readOnly = true
|
||||
)
|
||||
Button(onClick = { /* Öffnet Directory Picker */ }) { Text("...") }
|
||||
}
|
||||
Button(
|
||||
onClick = { /*TODO*/ },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = false
|
||||
) { Text("Ergebnis-Export (.erg)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text("Offline-Sync (USB-Stick / Lokales Netzwerk)", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Übertragen Sie den kompletten Turnierstand zwischen Master-Meldestelle und Richterturm-Laptops ohne Internet.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
label = { Text("Ziel-Pfad (z.B. D:/Export)") },
|
||||
modifier = Modifier.weight(1f),
|
||||
readOnly = true
|
||||
)
|
||||
Button(onClick = { /* Öffnet Directory Picker */ }) { Text("...") }
|
||||
}
|
||||
OutlinedButton(onClick = { /*TODO*/ }, modifier = Modifier.fillMaxWidth()) { Text("↑ Turnier Exportieren") }
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
label = { Text("Quell-Datei (z.B. D:/turnier.db)") },
|
||||
modifier = Modifier.weight(1f),
|
||||
readOnly = true
|
||||
)
|
||||
Button(onClick = { /* Öffnet File Picker */ }) { Text("...") }
|
||||
}
|
||||
OutlinedButton(onClick = { /*TODO*/ }, modifier = Modifier.fillMaxWidth()) { Text("↓ Turnier Importieren") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TournamentStepStammdaten() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(0.6f)) {
|
||||
Text("Schritt 2: Turnier-Stammdaten", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
OutlinedTextField(
|
||||
value = "CSN-C NEU Neumarkt",
|
||||
onValueChange = {},
|
||||
label = { Text("Turniername") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = "25.04.2026",
|
||||
onValueChange = {},
|
||||
label = { Text("Datum von") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = "25.04.2026",
|
||||
onValueChange = {},
|
||||
label = { Text("Datum bis") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TournamentStepKonfiguration() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(0.6f)) {
|
||||
Text("Schritt 3: Konfiguration", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Austragungsplätze und Preisliste")
|
||||
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Austragungsplätze", fontWeight = FontWeight.Bold)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
|
||||
FilterChip(selected = true, onClick = {}, label = { Text("Platz 1 (Sand/Vlies 45x65m)") })
|
||||
FilterChip(selected = false, onClick = {}, label = { Text("Halle (Sand/Vlies 20x40m)") })
|
||||
FilterChip(selected = false, onClick = {}, label = { Text("+ Hinzufügen") })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TournamentStepFunktionaere() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(0.6f)) {
|
||||
Text("Schritt 4: Team & Funktionäre", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Zuweisung von Richtern und Parcoursbauern (aus ZNS)")
|
||||
|
||||
OutlinedTextField(
|
||||
value = "Rudi Kreupl",
|
||||
onValueChange = {},
|
||||
label = { Text("Turnierbeauftragter (Suche nach Name oder ID)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = "Helmut Riedler",
|
||||
onValueChange = {},
|
||||
label = { Text("Richter (Suche nach Name oder ID)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = "Kurt Reitetschlägerr",
|
||||
onValueChange = {},
|
||||
label = { Text("Parcoursbauchef") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TournamentStepBewerbe() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxSize()) {
|
||||
Text("Schritt 5: Bewerbe anlegen", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
// Left: List of Bewerbe
|
||||
Card(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Bewerbe", fontWeight = FontWeight.Bold)
|
||||
TextButton(onClick = {}) { Text("+ Neu") }
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text("1: Pony Stilspringprüfung (60cm)", modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text("2: Einlaufspringprüfung (60cm)", modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text("3: Pony Stilspringprüfung (70cm)", modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text("4: Einlaufspringprüfung (70cm)", modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text("...", modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
// Right: Detail Tabs for selected Bewerb
|
||||
Card(modifier = Modifier.weight(2f).fillMaxHeight()) {
|
||||
Column {
|
||||
PrimaryTabRow(selectedTabIndex = 0) {
|
||||
Tab(selected = true, onClick = {}, text = { Text("Bewertung") })
|
||||
Tab(selected = false, onClick = {}, text = { Text("Geldpreis") })
|
||||
Tab(selected = false, onClick = {}, text = { Text("Ort/Zeit") })
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
OutlinedTextField(
|
||||
value = "2",
|
||||
onValueChange = {},
|
||||
label = { Text("Bewerb Nr.") },
|
||||
modifier = Modifier.width(100.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = "Einlaufspringprüfung",
|
||||
onValueChange = {},
|
||||
label = { Text("Bezeichnung") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
OutlinedTextField(
|
||||
value = "60cm",
|
||||
onValueChange = {},
|
||||
label = { Text("Klasse / Höhe") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = "§ 218",
|
||||
onValueChange = {},
|
||||
label = { Text("Richtverfahren") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Text("Abteilungen", fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = true, onCheckedChange = {})
|
||||
Text("1. Abt: lizenzfrei")
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = true, onCheckedChange = {})
|
||||
Text("2. Abt: mit Lizenz")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,382 +0,0 @@
|
|||
package screens
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
import at.mocode.frontend.core.domain.PlatformType
|
||||
import at.mocode.frontend.core.domain.currentPlatform
|
||||
import components.ToggleRow
|
||||
import components.TournamentData
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
authTokenManager: AuthTokenManager,
|
||||
onLogout: () -> Unit,
|
||||
onCreateTournament: () -> Unit,
|
||||
onNennungOeffnen: () -> Unit = {},
|
||||
) {
|
||||
val authState by authTokenManager.authState.collectAsState()
|
||||
val scrollState = rememberScrollState()
|
||||
val isDesktop = currentPlatform() == PlatformType.DESKTOP
|
||||
|
||||
// Security Check für das Dashboard
|
||||
if (!authState.isAuthenticated) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val isAdmin = authTokenManager.isAdmin()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// App Header (Meldestelle Toolbar)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = if (isDesktop) "Master-Meldestelle Steuerungszentrale" else if (isAdmin) "Admin-Dashboard (Web)" else "Veranstalter-Dashboard",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Angemeldet als: ${authState.username ?: "Admin"}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
TextButton(onClick = onLogout) {
|
||||
Text("Abmelden")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main Content Area
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
|
||||
if (isDesktop && isAdmin) {
|
||||
// DESKTOP VIEW - STEUERUNGSZENTRALE FÜR DEN ADMIN
|
||||
Button(
|
||||
onClick = onNennungOeffnen,
|
||||
modifier = Modifier.fillMaxWidth().height(64.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "📋 Nennungs-Maske öffnen",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onCreateTournament,
|
||||
modifier = Modifier.fillMaxWidth().height(64.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "+ neues Turnier anlegen",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Alle verwalteten Turniere",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
// Filters (Mockup)
|
||||
Surface(
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Zeitraum:", style = MaterialTheme.typography.bodyMedium)
|
||||
OutlinedTextField(
|
||||
value = "März",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(100.dp).height(48.dp),
|
||||
singleLine = true
|
||||
)
|
||||
Text("bis", style = MaterialTheme.typography.bodyMedium)
|
||||
OutlinedTextField(
|
||||
value = "Dezember",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(120.dp).height(48.dp),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = "2026",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(80.dp).height(48.dp),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = "Bundesland",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(150.dp).height(48.dp),
|
||||
singleLine = true
|
||||
)
|
||||
Button(
|
||||
onClick = {},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE67E22))
|
||||
) {
|
||||
Text("Anzeigen")
|
||||
}
|
||||
}
|
||||
Text("Zusätzliche Filter auf Suchergebnisse:", style = MaterialTheme.typography.bodyMedium)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = "Veranstalter (Verein)",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(180.dp).height(48.dp),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = "Ort",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(120.dp).height(48.dp),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = "Sparte",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(120.dp).height(48.dp),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = "Turnierart",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.width(120.dp).height(48.dp),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop Tournament Card (Steuerungszentrale Ansicht)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border = BorderStroke(2.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(120.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("NEUMARKT\nLOGO", textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("CDN-C NEU CDNP-C", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Veranstalter: URFV Neumarkt",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text("NEUMARKT/M., OÖ 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium)
|
||||
Text("Turnier-Nr.: 26129", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
|
||||
var meldestelleOnline by remember { mutableStateOf(true) }
|
||||
var nennsystemOnline by remember { mutableStateOf(true) }
|
||||
var startlisteOnline by remember { mutableStateOf(true) }
|
||||
ToggleRow("Meldestelle-Desktop online", isOnline = meldestelleOnline, isInteractive = false)
|
||||
ToggleRow("Nennsystem online", isOnline = nennsystemOnline, isInteractive = true)
|
||||
ToggleRow("Start- Ergebnislisten online", isOnline = startlisteOnline, isInteractive = true)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = { /* Link kopieren oder Email Dialog öffnen */ },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Veranstalter-Link senden")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else if (isDesktop && !isAdmin) {
|
||||
// DESKTOP VIEW - VERANSTALTER (Meldestelle am Platz)
|
||||
Text(
|
||||
"Willkommen in der Meldestellen-Software für Turnier 26129",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Text("Bitte initialisieren Sie die lokale Datenbank oder importieren Sie einen Stand vom USB-Stick.")
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = onCreateTournament) {
|
||||
Text("Turnier initialisieren / Importieren")
|
||||
}
|
||||
} else if (!isDesktop && isAdmin) {
|
||||
// WEB VIEW - ADMIN PORTAL
|
||||
Text(
|
||||
text = "Alle verwalteten Turniere",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"CDN-C NEU CDNP-C Neumarkt",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text("Veranstalter: URFV Neumarkt", style = MaterialTheme.typography.bodyMedium)
|
||||
Text("Nr: 26129 | 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Button(onClick = { /* TODO: Download Trigger for Master App */ }) {
|
||||
Text("Master-Desktop-App herunterladen")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// WEB VIEW - VERANSTALTER PORTAL
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"Aktuelles Turnier: CDN-C NEU CDNP-C Neumarkt",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
"Turnier-Nr.: 26129 | 26. APRIL 2026",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
"Bitte laden Sie die Desktop-Anwendung herunter, um die Meldestelle lokal an Ihrem Turnierplatz zu betreiben.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
) {
|
||||
Button(onClick = { /* TODO: Download Trigger for generic app */ }) {
|
||||
Text("Meldestelle-App herunterladen (.exe)")
|
||||
}
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
|
||||
) {
|
||||
Text(
|
||||
"Ihr Aktivierungs-Code: X7F9-K2M4",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Turnier-Historie
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Turnier-Historie (Archiv)", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
val turniere = listOf(
|
||||
TournamentData(
|
||||
id = "25044",
|
||||
date = "24. APRIL 2025",
|
||||
title = "CSN-C CSNP-C Neumarkt",
|
||||
location = "NEUMARKT/M., OÖ"
|
||||
),
|
||||
TournamentData(
|
||||
id = "24012",
|
||||
date = "28. APRIL 2024",
|
||||
title = "CDN-C CDNP-C Neumarkt",
|
||||
location = "NEUMARKT/M., OÖ"
|
||||
)
|
||||
)
|
||||
|
||||
turniere.forEach { turnier ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(turnier.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Nr: ${turnier.id} | ${turnier.date}", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { /* TODO: Open PDF Archive */ }) {
|
||||
Text("Ergebnislisten (PDF)")
|
||||
}
|
||||
OutlinedButton(onClick = { /* TODO: Open Stats */ }) {
|
||||
Text("Statistiken")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
package screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.components.AppFooter
|
||||
|
||||
@Composable
|
||||
fun LandingScreen(
|
||||
onPrimaryCta: () -> Unit,
|
||||
onOpenPing: () -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
// Top Bar
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "mo-code.at",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Button(onClick = onPrimaryCta) {
|
||||
Text("Login Meldestelle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hero
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 60.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Die moderne Meldestelle",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "Von Praktikern für Praktiker. Schneller Nennen, fehlerfrei Richten und stressfrei Auswerten – konform nach ÖTO & FEI.",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Manifest / Intro
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 60.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("Unser Anspruch: Ein durchdachtes System.", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"Die Meldestelle ist das Herzstück jedes Turniers. Wenn sie stolpert, stockt der Sport. Wir verstehen den Balanceakt zwischen Veranstaltern, Reitern und den Verbänden.",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
"Deshalb entwickeln wir diese Plattform nicht am Reißbrett, sondern direkt am Turnierplatz – aus der Sicht der Meldestelle, der Richter, der Zeitnehmer und aller Funktionäre.",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
"Mit Fokus auf die Praxis: Tastaturbedienung für höchste Geschwindigkeit, Offline-Fähigkeit für das 'Plumpsklo' am Rand des Abreiteplatzes und eine integrierte Kassenführung.",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Kern-Säulen
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 60.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp)
|
||||
) {
|
||||
Text("Die Kern-Säulen", style = MaterialTheme.typography.headlineMedium)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
LandingFeatureCard(
|
||||
number = "01",
|
||||
title = "Regelwerks-Intelligenz (ÖTO)",
|
||||
body = "Wir nehmen Ihnen die Validierungs-Komplexität ab. Von der Lizenzprüfung der Reiter bis zur Kontrolle der Richterqualifikationen beim Anlegen der Bewerbe."
|
||||
)
|
||||
LandingFeatureCard(
|
||||
number = "02",
|
||||
title = "Offline-First & Resilient",
|
||||
body = "Stabil auf dem Laptop. Dank Offline-Unterstützung und lokaler Datenbank arbeiten Sie nahtlos weiter, selbst wenn die Internetverbindung am Platz wieder einmal abreißt."
|
||||
)
|
||||
LandingFeatureCard(
|
||||
number = "03",
|
||||
title = "Speed-Workflow",
|
||||
body = "Die Nennungsmaske und die Ergebniserfassung sind kompromisslos auf Geschwindigkeit und Tastaturbedienung (Enter & Tab) optimiert. Weil am Turniertag jede Sekunde zählt."
|
||||
)
|
||||
LandingFeatureCard(
|
||||
number = "04",
|
||||
title = "Smarte Kassenführung",
|
||||
body = "Kontobasierte Abrechnung für Reiter und Besitzer. Nenngelder, Startgelder und Nachnenngebühren sauber getrennt – selbst ein Nennungstausch wird als einfacher Transfer verbucht."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// System Status Link
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedButton(onClick = onOpenPing) {
|
||||
Text("System Status (Ping-Service)")
|
||||
}
|
||||
}
|
||||
|
||||
AppFooter()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LandingFeatureCard(number: String, title: String, body: String) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = number,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Black,
|
||||
modifier = Modifier.width(64.dp)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(body, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
package screens
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OrganizerProfileScreen(
|
||||
authTokenManager: AuthTokenManager,
|
||||
onLogout: () -> Unit,
|
||||
onNavigateToDashboard: () -> Unit
|
||||
) {
|
||||
val authState by authTokenManager.authState.collectAsState()
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// Formular-Felder
|
||||
var vereinsname by remember { mutableStateOf("URFV Neumarkt") }
|
||||
var vereinskuerzel by remember { mutableStateOf("URFV") }
|
||||
var adresse by remember { mutableStateOf("") }
|
||||
var plz by remember { mutableStateOf("") }
|
||||
var ort by remember { mutableStateOf("") }
|
||||
var land by remember { mutableStateOf("Österreich") }
|
||||
var mapsLink by remember { mutableStateOf("") }
|
||||
|
||||
// Ansprechpersonen
|
||||
var kontakt1Name by remember { mutableStateOf("") }
|
||||
var kontakt1Email by remember { mutableStateOf("") }
|
||||
var kontakt1Telefon by remember { mutableStateOf("") }
|
||||
var kontakt2Name by remember { mutableStateOf("") }
|
||||
var kontakt2Email by remember { mutableStateOf("") }
|
||||
var kontakt2Telefon by remember { mutableStateOf("") }
|
||||
|
||||
// Social / Links
|
||||
var webseite by remember { mutableStateOf("") }
|
||||
var facebook by remember { mutableStateOf("") }
|
||||
var instagram by remember { mutableStateOf("") }
|
||||
var youtube by remember { mutableStateOf("") }
|
||||
|
||||
// Weitere Infos
|
||||
var vereinsbeschreibung by remember { mutableStateOf("") }
|
||||
var bankverbindung by remember { mutableStateOf("") }
|
||||
var uid by remember { mutableStateOf("") }
|
||||
|
||||
var saveSuccess by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Veranstalter Profil") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateToDashboard) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Text(
|
||||
text = authState.username ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
TextButton(onClick = onLogout) { Text("Abmelden") }
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
|
||||
// --- Logo & Vereinsname ---
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("Verein / Veranstalter", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
|
||||
// Logo Placeholder
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text("🏆", style = MaterialTheme.typography.displayMedium)
|
||||
Text(
|
||||
"Vereins-/Veranstaltungslogo",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
OutlinedButton(onClick = { /* TODO: File Picker */ }) {
|
||||
Text("Logo hochladen")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = vereinsname,
|
||||
onValueChange = { vereinsname = it },
|
||||
label = { Text("Vereinsname / Veranstalter") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = vereinskuerzel,
|
||||
onValueChange = { vereinskuerzel = it },
|
||||
label = { Text("Kürzel") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = vereinsbeschreibung,
|
||||
onValueChange = { vereinsbeschreibung = it },
|
||||
label = { Text("Kurzbeschreibung / Über uns") },
|
||||
minLines = 3,
|
||||
maxLines = 6,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Adresse ---
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("Adresse", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
|
||||
OutlinedTextField(
|
||||
value = adresse,
|
||||
onValueChange = { adresse = it },
|
||||
label = { Text("Straße & Hausnummer") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = plz,
|
||||
onValueChange = { plz = it },
|
||||
label = { Text("PLZ") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.width(100.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = ort,
|
||||
onValueChange = { ort = it },
|
||||
label = { Text("Ort") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = land,
|
||||
onValueChange = { land = it },
|
||||
label = { Text("Land") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = mapsLink,
|
||||
onValueChange = { mapsLink = it },
|
||||
label = { Text("Google Maps / OpenStreetMap Link") },
|
||||
placeholder = { Text("https://maps.google.com/...") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ansprechpersonen ---
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("Ansprechpersonen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
|
||||
Text("Hauptkontakt", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
OutlinedTextField(
|
||||
value = kontakt1Name,
|
||||
onValueChange = { kontakt1Name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = kontakt1Email,
|
||||
onValueChange = { kontakt1Email = it },
|
||||
label = { Text("E-Mail") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = kontakt1Telefon,
|
||||
onValueChange = { kontakt1Telefon = it },
|
||||
label = { Text("Telefon / Mobil") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
"Weiterer Kontakt (optional)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = kontakt2Name,
|
||||
onValueChange = { kontakt2Name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = kontakt2Email,
|
||||
onValueChange = { kontakt2Email = it },
|
||||
label = { Text("E-Mail") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = kontakt2Telefon,
|
||||
onValueChange = { kontakt2Telefon = it },
|
||||
label = { Text("Telefon / Mobil") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Social Media & Links ---
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("Links & Social Media", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
|
||||
OutlinedTextField(
|
||||
value = webseite,
|
||||
onValueChange = { webseite = it },
|
||||
label = { Text("Webseite") },
|
||||
placeholder = { Text("https://www.meinverein.at") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = facebook,
|
||||
onValueChange = { facebook = it },
|
||||
label = { Text("Facebook") },
|
||||
placeholder = { Text("https://facebook.com/...") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = instagram,
|
||||
onValueChange = { instagram = it },
|
||||
label = { Text("Instagram") },
|
||||
placeholder = { Text("https://instagram.com/...") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = youtube,
|
||||
onValueChange = { youtube = it },
|
||||
label = { Text("YouTube") },
|
||||
placeholder = { Text("https://youtube.com/...") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Weitere Vereinsdaten ---
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
|
||||
OutlinedTextField(
|
||||
value = bankverbindung,
|
||||
onValueChange = { bankverbindung = it },
|
||||
label = { Text("IBAN / Bankverbindung") },
|
||||
placeholder = { Text("AT12 3456 7890 1234 5678") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uid,
|
||||
onValueChange = { uid = it },
|
||||
label = { Text("UID-Nummer / ZVR-Zahl") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Speichern ---
|
||||
if (saveSuccess) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"✓ Profil erfolgreich gespeichert!",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
// TODO: Backend-Anbindung (PUT /api/organizer/profile)
|
||||
saveSuccess = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||
) {
|
||||
Text("Profil speichern", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onNavigateToDashboard,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Zum Dashboard")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ComposeAppCommonTest {
|
||||
|
||||
@Test
|
||||
fun example() {
|
||||
assertEquals(3, 1 + 2)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import kotlinx.browser.window
|
||||
import kotlinx.coroutines.await
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.w3c.fetch.RequestInit
|
||||
|
||||
private val AppJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Serializable
|
||||
data class AppConfig(
|
||||
val apiBaseUrl: String,
|
||||
val keycloakUrl: String? = null // Optional, da nicht immer vorhanden
|
||||
)
|
||||
|
||||
suspend fun loadAppConfig(): AppConfig {
|
||||
return try {
|
||||
// ?_nocache erzwingt einen SW-bypassing Request (SW sieht andere URL → Cache-Miss → Netzwerk)
|
||||
val response = window.fetch("/config.json?_nocache=1", RequestInit()).await()
|
||||
if (!response.ok) {
|
||||
console.warn("[Config] Failed to load config.json, falling back to globalThis")
|
||||
return fallbackFromGlobal()
|
||||
}
|
||||
val text = response.text().await()
|
||||
AppJson.decodeFromString(AppConfig.serializer(), text)
|
||||
} catch (e: dynamic) {
|
||||
console.error("[Config] Error loading config:", e)
|
||||
// Fallback: Caddy-injizierte Werte aus index.html (globalThis.API_BASE_URL / KEYCLOAK_URL)
|
||||
fallbackFromGlobal()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fallbackFromGlobal(): AppConfig {
|
||||
val apiBase = (js("globalThis.API_BASE_URL") as? String)
|
||||
?.takeIf { it.isNotBlank() && !it.startsWith($$"${") }
|
||||
?: window.location.origin
|
||||
val kcUrl = (js("globalThis.KEYCLOAK_URL") as? String)
|
||||
?.takeIf { it.isNotBlank() && !it.startsWith($$"${") }
|
||||
console.log("[Config] Fallback: apiBaseUrl=$apiBase, keycloakUrl=$kcUrl")
|
||||
return AppConfig(apiBaseUrl = apiBase, keycloakUrl = kcUrl)
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
actual fun isDevelopmentMode(): Boolean =
|
||||
kotlinx.browser.window.location.hostname == "localhost"
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun NennungScreenContent() {
|
||||
// Nennungs-Maske ist nur für Desktop (JVM) verfügbar
|
||||
Text("Nennungs-Maske ist nur in der Desktop-App verfügbar.")
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.ComposeViewport
|
||||
import at.mocode.frontend.core.auth.di.authModule
|
||||
import at.mocode.frontend.core.localdb.AppDatabase
|
||||
import at.mocode.frontend.core.localdb.DatabaseProvider
|
||||
import at.mocode.frontend.core.localdb.localDbModule
|
||||
import at.mocode.frontend.core.network.networkModule
|
||||
import at.mocode.frontend.core.sync.di.syncModule
|
||||
import at.mocode.ping.feature.di.pingFeatureModule
|
||||
import kotlinx.browser.document
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import navigation.navigationModule
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.context.loadKoinModules
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.dsl.module
|
||||
import org.w3c.dom.HTMLElement
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
console.log("[WebApp] main() entered")
|
||||
|
||||
MainScope().launch {
|
||||
try {
|
||||
// 1. Load Runtime Configuration (Async)
|
||||
console.log("[WebApp] Loading configuration...")
|
||||
val config = loadAppConfig()
|
||||
console.log("[WebApp] Configuration loaded: apiBaseUrl=${config.apiBaseUrl}, keycloakUrl=${config.keycloakUrl}")
|
||||
|
||||
// Inject config into the global JS scope for PlatformConfig to find
|
||||
val global = js("typeof globalThis !== 'undefined' ? globalThis : window")
|
||||
global.API_BASE_URL = config.apiBaseUrl
|
||||
config.keycloakUrl?.let { global.KEYCLOAK_URL = it }
|
||||
|
||||
// 2. Initialize DI (Koin)
|
||||
// We register the config immediately so other modules can use it
|
||||
startKoin {
|
||||
modules(
|
||||
module { single { config } }, // Make AppConfig available for injection
|
||||
networkModule,
|
||||
localDbModule,
|
||||
syncModule,
|
||||
pingFeatureModule,
|
||||
authModule,
|
||||
navigationModule
|
||||
)
|
||||
}
|
||||
console.log("[WebApp] Koin initialized")
|
||||
|
||||
// 3. Initialize Database (Async)
|
||||
val provider = GlobalContext.get().get<DatabaseProvider>()
|
||||
console.log("[WebApp] Initializing Database...")
|
||||
val db = provider.createDatabase()
|
||||
|
||||
// Register the created DB instance into Koin
|
||||
loadKoinModules(
|
||||
module {
|
||||
single<AppDatabase> { db }
|
||||
}
|
||||
)
|
||||
console.log("[WebApp] Local DB created and registered in Koin")
|
||||
|
||||
// 4. Start UI
|
||||
startAppWhenDomReady()
|
||||
|
||||
} catch (e: dynamic) {
|
||||
console.error("[WebApp] CRITICAL: Initialization failed:", e)
|
||||
renderFatalError("Initialization failed: ${e?.message ?: e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun startAppWhenDomReady() {
|
||||
val state = document.asDynamic().readyState as String?
|
||||
if (state == "interactive" || state == "complete") {
|
||||
mountComposeApp()
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", { mountComposeApp() })
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun mountComposeApp() {
|
||||
try {
|
||||
console.log("[WebApp] Mounting Compose App...")
|
||||
val root = document.getElementById("ComposeTarget") as HTMLElement
|
||||
|
||||
ComposeViewport(root) {
|
||||
MainApp()
|
||||
}
|
||||
|
||||
// Remove loading spinner
|
||||
(document.querySelector(".loading") as? HTMLElement)?.let { it.parentElement?.removeChild(it) }
|
||||
console.log("[WebApp] App mounted successfully")
|
||||
|
||||
} catch (e: Exception) {
|
||||
console.error("Failed to start Compose Web app", e)
|
||||
renderFatalError("UI Mount failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun renderFatalError(message: String) {
|
||||
val fallbackTarget = (document.getElementById("ComposeTarget") ?: document.body) as HTMLElement
|
||||
fallbackTarget.innerHTML = """
|
||||
<div style='padding: 50px; text-align: center; color: #D32F2F; font-family: sans-serif;'>
|
||||
<h1>System Error</h1>
|
||||
<p>The application could not be started.</p>
|
||||
<pre style='background: #FFEBEE; padding: 10px; border-radius: 4px; text-align: left; display: inline-block;'>$message</pre>
|
||||
</div>
|
||||
""".trimIndent()
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 560 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 667 KiB |
|
|
@ -1,73 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meldestelle - Web</title>
|
||||
<link type="text/css" rel="stylesheet" href="styles.css">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
</head>
|
||||
<body>
|
||||
<div id="ComposeTarget">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Schritt 1: Der Container-Entrypoint ersetzt ${API_BASE_URL} und ${KEYCLOAK_URL}
|
||||
via envsubst beim Start. Caddy bekommt eine fertige statische Datei ohne Template-Parsing.
|
||||
-->
|
||||
<script id="app-config" type="application/json">
|
||||
{
|
||||
"apiBaseUrl": "${API_BASE_URL}",
|
||||
"keycloakUrl": "${KEYCLOAK_URL}"
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Schritt 2: Das Haupt-Skript liest die Konfiguration aus dem JSON-Block.
|
||||
(function () {
|
||||
try {
|
||||
const configElement = document.getElementById('app-config');
|
||||
const configJson = configElement.textContent || '{}';
|
||||
const config = JSON.parse(configJson);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const notReplaced = '${'; // Fallback-Erkennung: envsubst hat den Wert NICHT ersetzt
|
||||
const apiFromCaddy = config.apiBaseUrl;
|
||||
const apiOverride = params.get('apiBaseUrl');
|
||||
globalThis.API_BASE_URL = apiOverride
|
||||
? apiOverride.replace(/\/$/, '')
|
||||
: (apiFromCaddy && !apiFromCaddy.startsWith(notReplaced)
|
||||
? apiFromCaddy.replace(/\/$/, '')
|
||||
: 'http://' + window.location.hostname + ':8081');
|
||||
|
||||
const kcFromCaddy = config.keycloakUrl;
|
||||
const kcOverride = params.get('keycloakUrl');
|
||||
globalThis.KEYCLOAK_URL = kcOverride
|
||||
? kcOverride.replace(/\/$/, '')
|
||||
: (kcFromCaddy && !kcFromCaddy.startsWith(notReplaced)
|
||||
? kcFromCaddy.replace(/\/$/, '')
|
||||
: 'http://' + window.location.hostname + ':8180');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error loading runtime configuration:', e);
|
||||
globalThis.API_BASE_URL = 'http://localhost:8081';
|
||||
globalThis.KEYCLOAK_URL = 'http://localhost:8180';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="web-app.js"></script>
|
||||
<script>
|
||||
// Register Service Worker only in non-localhost environments
|
||||
if ('serviceWorker' in navigator && !['localhost', '127.0.0.1', '::1'].includes(location.hostname)) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function (err) {
|
||||
console.warn('ServiceWorker registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "Meldestelle",
|
||||
"short_name": "Melde",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #fafafa;
|
||||
overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */
|
||||
}
|
||||
|
||||
#ComposeTarget {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
const IS_DEV = self.location.hostname === 'localhost' || self.location.hostname === '127.0.0.1' || self.location.hostname === '::1';
|
||||
|
||||
const CACHE_NAME = 'meldestelle-cache-v4';
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/styles.css'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
if (IS_DEV) {
|
||||
// In dev, don't precache. Just activate the SW immediately.
|
||||
self.skipWaiting().then(_ => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
if (IS_DEV) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
return;
|
||||
}
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (IS_DEV) {
|
||||
return; // don't interfere with dev server/HMR
|
||||
}
|
||||
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
|
||||
const isHttp = url.protocol === 'http:' || url.protocol === 'https:';
|
||||
const sameOrigin = url.origin === self.location.origin;
|
||||
const isExtension = url.protocol === 'chrome-extension:';
|
||||
const isHotUpdate = url.pathname.includes('hot-update');
|
||||
const isWebSocketUpgrade = req.headers.get('upgrade') === 'websocket';
|
||||
|
||||
// Ignore non-GET, cross-origin, browser extensions, HMR, and WebSocket upgrade requests
|
||||
if (req.method !== 'GET' || !isHttp || !sameOrigin || isExtension || isHotUpdate || isWebSocketUpgrade) {
|
||||
return; // Let the browser handle it
|
||||
}
|
||||
|
||||
if (req.mode === 'navigate') {
|
||||
// Network-first for navigation
|
||||
event.respondWith(
|
||||
fetch(req)
|
||||
.then((resp) => {
|
||||
if (resp && resp.status === 200 && resp.type === 'basic') {
|
||||
const copy = resp.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy)).catch(() => {
|
||||
});
|
||||
}
|
||||
return resp;
|
||||
})
|
||||
.catch(() => caches.match('/index.html'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Laufzeit-Config immer direkt vom Netzwerk – niemals aus dem Cache
|
||||
if (url.pathname === '/config.json') {
|
||||
event.respondWith(fetch(req).catch(() => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
return;
|
||||
}
|
||||
|
||||
// API-Requests immer direkt vom Netzwerk – niemals aus dem Cache (Auth-Header müssen erhalten bleiben)
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(fetch(req));
|
||||
return;
|
||||
}
|
||||
|
||||
// App-Bundle immer vom Netzwerk – niemals aus dem Cache (verhindert veraltete JS-Versionen)
|
||||
if (url.pathname.endsWith('web-app.js') || url.pathname.endsWith('web-app.js.map')) {
|
||||
event.respondWith(fetch(req));
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid noisy errors for favicon during dev/prod when missing
|
||||
if (url.pathname === '/favicon.ico') {
|
||||
event.respondWith(
|
||||
fetch(req).catch(() => caches.match(req))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets
|
||||
event.respondWith(
|
||||
caches.match(req).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(req)
|
||||
.then((resp) => {
|
||||
if (resp && resp.status === 200 && resp.type === 'basic') {
|
||||
const copy = resp.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {
|
||||
});
|
||||
}
|
||||
return resp;
|
||||
})
|
||||
.catch(() => caches.match(req));
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import at.mocode.frontend.core.designsystem.theme.AppTheme
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LandingPagePreview() {
|
||||
AppTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(Dimens.SpacingM),
|
||||
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
|
||||
) {
|
||||
Text(
|
||||
text = "Landing Page Preview",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Dies ist eine Live-Vorschau. Du kannst hier deine UI-Komponenten isoliert bauen und sofort sehen, wie sie aussehen. Ändere Text, Farben oder Layouts im Code – die Preview aktualisiert sich automatisch in IntelliJ (Hot-Reload).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Button(onClick = {}) {
|
||||
Text("Ein Button im Primary-Design")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback für IntelliJ, falls das Compose Plugin die Preview nicht erkennt
|
||||
fun main() = application {
|
||||
Window(onCloseRequest = ::exitApplication, title = "Design Preview Sandbox") {
|
||||
LandingPagePreview()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
actual fun isDevelopmentMode(): Boolean =
|
||||
System.getProperty("development.mode", "false").toBoolean()
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import androidx.compose.runtime.Composable
|
||||
import at.mocode.nennung.feature.presentation.NennungsMaske
|
||||
import at.mocode.nennung.feature.presentation.NennungViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
actual fun NennungScreenContent() {
|
||||
val viewModel: NennungViewModel = koinViewModel()
|
||||
NennungsMaske(viewModel = viewModel)
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.window.application
|
||||
import at.mocode.frontend.core.auth.di.authModule
|
||||
import at.mocode.frontend.core.localdb.AppDatabase
|
||||
import at.mocode.frontend.core.localdb.DatabaseProvider
|
||||
import at.mocode.frontend.core.localdb.localDbModule
|
||||
import at.mocode.frontend.core.network.networkModule
|
||||
import at.mocode.frontend.core.sync.di.syncModule
|
||||
import at.mocode.ping.feature.di.pingFeatureModule
|
||||
import at.mocode.nennung.feature.di.nennungFeatureModule
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import navigation.navigationModule
|
||||
import org.koin.core.context.loadKoinModules
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
fun main() = application {
|
||||
// Initialize DI (Koin) with shared modules + network module
|
||||
try {
|
||||
// Updated: Only load the consolidated pingFeatureModule from at.mocode.ping.feature.di
|
||||
startKoin {
|
||||
modules(
|
||||
networkModule,
|
||||
syncModule,
|
||||
pingFeatureModule,
|
||||
nennungFeatureModule,
|
||||
authModule,
|
||||
navigationModule,
|
||||
localDbModule
|
||||
)
|
||||
}
|
||||
println("[DesktopApp] Koin initialized with networkModule + authModule + navigationModule + pingFeatureModule + localDbModule")
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopApp] Koin initialization warning: ${e.message}")
|
||||
}
|
||||
|
||||
// Create the local DB once and register it into Koin so feature repositories can resolve it.
|
||||
try {
|
||||
val provider = org.koin.core.context.GlobalContext.get().get<DatabaseProvider>()
|
||||
val db = runBlocking { provider.createDatabase() }
|
||||
loadKoinModules(
|
||||
module {
|
||||
single<AppDatabase> { db }
|
||||
}
|
||||
)
|
||||
println("[DesktopApp] Local DB created and registered in Koin")
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopApp] Local DB init warning: ${e.message}")
|
||||
}
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Master Desktop",
|
||||
state = WindowState(width = 1200.dp, height = 800.dp)
|
||||
) {
|
||||
MainApp()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.ComposeViewport
|
||||
import kotlinx.browser.document
|
||||
import org.w3c.dom.HTMLElement
|
||||
|
||||
import at.mocode.shared.di.initKoin
|
||||
import at.mocode.frontend.core.network.networkModule
|
||||
import at.mocode.clients.authfeature.di.authFeatureModule
|
||||
import at.mocode.frontend.core.sync.di.syncModule
|
||||
import at.mocode.ping.feature.di.pingFeatureModule
|
||||
import at.mocode.frontend.core.localdb.AppDatabase
|
||||
import at.mocode.frontend.core.localdb.DatabaseProvider
|
||||
import navigation.navigationModule
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.context.loadKoinModules
|
||||
import org.koin.dsl.module
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
// Initialize DI
|
||||
try {
|
||||
initKoin { modules(networkModule, syncModule, pingFeatureModule, authFeatureModule, navigationModule) }
|
||||
println("[WasmApp] Koin initialized (with navigationModule)")
|
||||
} catch (e: Exception) {
|
||||
println("[WasmApp] Koin init failed: ${e.message}")
|
||||
}
|
||||
|
||||
// Create the local DB asynchronously and register it into Koin.
|
||||
try {
|
||||
val provider = GlobalContext.get().get<DatabaseProvider>()
|
||||
MainScope().launch {
|
||||
try {
|
||||
val db = provider.createDatabase()
|
||||
loadKoinModules(
|
||||
module {
|
||||
single<AppDatabase> { db }
|
||||
}
|
||||
)
|
||||
println("[WasmApp] Local DB created and registered in Koin")
|
||||
} catch (e: dynamic) {
|
||||
println("[WasmApp] Local DB init warning: ${e?.message ?: e}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[WasmApp] Local DB init warning: ${e.message}")
|
||||
}
|
||||
|
||||
val root = document.getElementById("ComposeTarget") as HTMLElement
|
||||
ComposeViewport(root) {
|
||||
MainApp()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
// Essenzielle Header für OPFS Support (SharedArrayBuffer)
|
||||
// Siehe: https://sqlite.org/wasm/doc/trunk/persistence.html#opfs
|
||||
config.devServer = config.devServer || {};
|
||||
config.devServer.headers = {
|
||||
...config.devServer.headers,
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp"
|
||||
};
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
// Webpack configuration for SQLite WASM support AND Skiko fixes
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
console.log("SQLite Config: Current working directory (cwd):", process.cwd());
|
||||
console.log("SQLite Config: __dirname:", __dirname);
|
||||
|
||||
config.resolve = config.resolve || {};
|
||||
config.resolve.fallback = config.resolve.fallback || {};
|
||||
config.resolve.alias = config.resolve.alias || {};
|
||||
|
||||
// 1. Fallbacks for Node.js core modules
|
||||
config.resolve.fallback.fs = false;
|
||||
config.resolve.fallback.path = false;
|
||||
config.resolve.fallback.crypto = false;
|
||||
|
||||
// 2. Resolve sqlite3 paths
|
||||
let sqliteBaseDir;
|
||||
try {
|
||||
const packagePath = path.dirname(require.resolve('@sqlite.org/sqlite-wasm/package.json'));
|
||||
sqliteBaseDir = path.join(packagePath, 'sqlite-wasm/jswasm');
|
||||
} catch (e) {
|
||||
console.warn("Could not resolve @sqlite.org/sqlite-wasm path automatically. Using fallback path.");
|
||||
sqliteBaseDir = path.resolve(__dirname, '../../../../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm');
|
||||
}
|
||||
|
||||
// 3. Copy ALL sqlite3 assets (wasm, js, and auxiliary workers)
|
||||
const copyPatterns = [];
|
||||
|
||||
if (fs.existsSync(sqliteBaseDir)) {
|
||||
console.log("Copying sqlite3 assets from:", sqliteBaseDir);
|
||||
copyPatterns.push({
|
||||
from: sqliteBaseDir,
|
||||
to: '.', // Copy to root of dist
|
||||
globOptions: {
|
||||
ignore: ['**/package.json']
|
||||
},
|
||||
noErrorOnMissing: true
|
||||
});
|
||||
} else {
|
||||
console.error("ERROR: sqlite3 base directory does not exist:", sqliteBaseDir);
|
||||
}
|
||||
|
||||
// 4. Copy sqlite.worker.js from source
|
||||
// Try multiple strategies to find the file
|
||||
|
||||
// Strategy A: Relative to __dirname (webpack.config.d)
|
||||
// ../../../core/local-db/src/jsMain/resources/sqlite.worker.js
|
||||
const pathA = path.resolve(__dirname, '../../../core/local-db/src/jsMain/resources/sqlite.worker.js');
|
||||
|
||||
// Strategy B: Relative to process.cwd() (project root usually)
|
||||
// ../../core/local-db/src/jsMain/resources/sqlite.worker.js (assuming cwd is meldestelle-portal)
|
||||
const pathB = path.resolve(process.cwd(), '../../core/local-db/src/jsMain/resources/sqlite.worker.js');
|
||||
|
||||
// Strategy C: Hardcoded fallback based on typical structure
|
||||
const pathC = path.resolve(__dirname, '../../../../core/local-db/src/jsMain/resources/sqlite.worker.js');
|
||||
// Strategy D: From processedResources of local-db module (Kotlin/JS build output)
|
||||
const pathD = path.resolve(__dirname, '../../../../core/local-db/build/processedResources/js/main/sqlite.worker.js');
|
||||
// Strategy E: Via process.cwd() 4 levels up = project root (works when Webpack runs from build/js/packages/<module>/)
|
||||
const pathE = path.resolve(process.cwd(), '../../../../frontend/core/local-db/src/jsMain/resources/sqlite.worker.js');
|
||||
// Strategy F: processedResources via process.cwd() 4 levels up
|
||||
const pathF = path.resolve(process.cwd(), '../../../../frontend/core/local-db/build/processedResources/js/main/sqlite.worker.js');
|
||||
// Strategy G: Via process.cwd() when Gradle runs from project root
|
||||
const pathG = path.resolve(process.cwd(), 'frontend/core/local-db/src/jsMain/resources/sqlite.worker.js');
|
||||
// Strategy H: processedResources via process.cwd() from project root
|
||||
const pathH = path.resolve(process.cwd(), 'frontend/core/local-db/build/processedResources/js/main/sqlite.worker.js');
|
||||
|
||||
let workerSourcePath = null;
|
||||
|
||||
if (fs.existsSync(pathA)) {
|
||||
workerSourcePath = pathA;
|
||||
console.log("Found sqlite.worker.js at (Strategy A):", pathA);
|
||||
} else if (fs.existsSync(pathB)) {
|
||||
workerSourcePath = pathB;
|
||||
console.log("Found sqlite.worker.js at (Strategy B):", pathB);
|
||||
} else if (fs.existsSync(pathC)) {
|
||||
workerSourcePath = pathC;
|
||||
console.log("Found sqlite.worker.js at (Strategy C):", pathC);
|
||||
} else if (fs.existsSync(pathD)) {
|
||||
workerSourcePath = pathD;
|
||||
console.log("Found sqlite.worker.js at (Strategy D - processedResources):", pathD);
|
||||
} else if (fs.existsSync(pathE)) {
|
||||
workerSourcePath = pathE;
|
||||
console.log("Found sqlite.worker.js at (Strategy E - build dir relative):", pathE);
|
||||
} else if (fs.existsSync(pathF)) {
|
||||
workerSourcePath = pathF;
|
||||
console.log("Found sqlite.worker.js at (Strategy F - build dir processedResources):", pathF);
|
||||
} else if (fs.existsSync(pathG)) {
|
||||
workerSourcePath = pathG;
|
||||
console.log("Found sqlite.worker.js at (Strategy G - cwd project root):", pathG);
|
||||
} else if (fs.existsSync(pathH)) {
|
||||
workerSourcePath = pathH;
|
||||
console.log("Found sqlite.worker.js at (Strategy H - cwd project root processedResources):", pathH);
|
||||
} else {
|
||||
console.error("ERROR: Could not find sqlite.worker.js in any expected location!");
|
||||
console.error("Checked A:", pathA);
|
||||
console.error("Checked B:", pathB);
|
||||
console.error("Checked C:", pathC);
|
||||
console.error("Checked D:", pathD);
|
||||
console.error("Checked E:", pathE);
|
||||
console.error("Checked F:", pathF);
|
||||
console.error("Checked G:", pathG);
|
||||
console.error("Checked H:", pathH);
|
||||
}
|
||||
|
||||
if (workerSourcePath) {
|
||||
copyPatterns.push({
|
||||
from: workerSourcePath,
|
||||
to: 'sqlite.worker.js',
|
||||
noErrorOnMissing: true
|
||||
});
|
||||
}
|
||||
|
||||
config.plugins.push(
|
||||
new CopyWebpackPlugin({
|
||||
patterns: copyPatterns
|
||||
})
|
||||
);
|
||||
|
||||
// 5. Alias sqlite3.wasm (still needed for some internal checks maybe)
|
||||
const sqliteWasmPath = path.join(sqliteBaseDir, 'sqlite3.wasm');
|
||||
config.resolve.alias['sqlite3.wasm'] = sqliteWasmPath;
|
||||
config.resolve.alias['./sqlite3.wasm'] = sqliteWasmPath;
|
||||
|
||||
// 6. Handle .wasm files
|
||||
config.experiments = config.experiments || {};
|
||||
config.experiments.asyncWebAssembly = true;
|
||||
|
||||
config.module = config.module || {};
|
||||
config.module.rules = config.module.rules || [];
|
||||
|
||||
// Treat Skiko WASM as resource to avoid parsing errors
|
||||
config.module.rules.push({
|
||||
test: /skiko\.wasm$/,
|
||||
type: 'asset/resource'
|
||||
});
|
||||
|
||||
// Treat other WASM as async (default)
|
||||
config.module.rules.push({
|
||||
test: /\.wasm$/,
|
||||
exclude: /skiko\.wasm$/,
|
||||
type: 'webassembly/async'
|
||||
});
|
||||
|
||||
// 7. Ignore warnings
|
||||
config.ignoreWarnings = config.ignoreWarnings || [];
|
||||
config.ignoreWarnings.push(/Critical dependency: the request of a dependency is an expression/);
|
||||
|
||||
// 8. Fix for "webpackEmptyContext" in sqlite3.mjs
|
||||
config.plugins.push(
|
||||
new webpack.ContextReplacementPlugin(
|
||||
/@sqlite\.org\/sqlite-wasm/,
|
||||
(data) => {
|
||||
delete data.dependencies;
|
||||
return data;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// 9. MIME types
|
||||
config.devServer = config.devServer || {};
|
||||
config.devServer.devMiddleware = config.devServer.devMiddleware || {};
|
||||
config.devServer.devMiddleware.mimeTypes = {
|
||||
'application/wasm': ['wasm'],
|
||||
'application/javascript': ['js']
|
||||
};
|
||||
|
||||
// 10. OPTIMIZATION: Handled by z_disable-minification.js
|
||||
// Minification is disabled in the alphabetically-last config file to ensure
|
||||
// it cannot be overridden by any subsequent Kotlin/JS or plugin step.
|
||||
// See: webpack.config.d/z_disable-minification.js
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
// HTML template will be handled by Kotlin/JS build system
|
||||
// No need for custom HtmlWebpackPlugin configuration
|
||||
|
||||
// Bundle-Analyze für Development (optional, only if package is available)
|
||||
if (process.env.ANALYZE_BUNDLE === 'true') {
|
||||
try {
|
||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||
config.plugins.push(new BundleAnalyzerPlugin({
|
||||
analyzerMode: 'static',
|
||||
openAnalyzer: false,
|
||||
reportFilename: 'bundle-report.html'
|
||||
}));
|
||||
console.log('Bundle analyzer enabled');
|
||||
} catch (e) {
|
||||
console.log('Bundle analyzer not available (webpack-bundle-analyzer not installed)');
|
||||
}
|
||||
}
|
||||
|
||||
// Hinweis: Wir liefern eine statische index.html aus src/jsMain/resources aus.
|
||||
// Diese Datei enthält nur ein Script-Tag zu "web-app.js" und wird NICHT
|
||||
// vom HtmlWebpackPlugin generiert. Zusätzliche Chunks (z. B. vendor/runtime)
|
||||
// würden dann nicht automatisch injiziert und führen dazu, dass die App nicht startet
|
||||
// (Bildschirm bleibt auf "Loading ...").
|
||||
//
|
||||
// Daher überschreiben wir config.optimization NICHT mehr mit splitChunks.
|
||||
// Wenn später Chunking gewünscht ist, muss die index.html durch das generierte
|
||||
// HTML ersetzt oder die zusätzlichen Chunks manuell eingebunden werden.
|
||||
//
|
||||
// (Frühere splitChunks-Konfiguration wurde bewusst entfernt.)
|
||||
|
||||
// Development Server Konfiguration erweitern
|
||||
if (config.devServer) {
|
||||
config.devServer = {
|
||||
...config.devServer,
|
||||
historyApiFallback: true,
|
||||
hot: true,
|
||||
// API Proxy für Backend-Anfragen (Array-Format für modernen Webpack)
|
||||
proxy: [
|
||||
{
|
||||
context: ['/api'],
|
||||
target: 'http://localhost:8081',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// WICHTIG: pathRewrite entfernt /api, wenn das Backend unter /api lauscht,
|
||||
// ist das falsch. Wenn das Backend unter / lauscht, ist es richtig.
|
||||
// Das API Gateway lauscht unter http://localhost:8081/api/...
|
||||
// Wenn wir also /api/ping aufrufen, soll es zu http://localhost:8081/api/ping gehen.
|
||||
// Daher KEIN pathRewrite, wenn das Gateway selbst /api erwartet.
|
||||
// Wenn das Gateway aber die Routen ohne /api mappt (z.B. /ping), dann brauchen wir Rewrite.
|
||||
//
|
||||
// Analyse:
|
||||
// Gateway Routes sind oft: /api/ping -> Ping Service /api/ping oder /ping
|
||||
// Wenn Gateway Routes definiert sind als:
|
||||
// - id: ping-service
|
||||
// uri: lb://ping-service
|
||||
// predicates:
|
||||
// - Path=/api/ping/**
|
||||
//
|
||||
// Dann leitet das Gateway /api/ping weiter.
|
||||
// Wenn wir pathRewrite machen, kommt beim Gateway nur /ping an.
|
||||
// Das Gateway matcht aber auf /api/ping.
|
||||
// Also: pathRewrite entfernen!
|
||||
// pathRewrite: {'^/api': ''}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
// ============================================================
|
||||
// FINAL GUARD: Disable ALL Minification for SQLite-WASM stability
|
||||
// ============================================================
|
||||
// WHY THIS FILE EXISTS:
|
||||
// Kotlin/JS loads webpack.config.d files in alphabetical order.
|
||||
// This file is prefixed with "z_" to ensure it runs LAST —
|
||||
// AFTER Kotlin/ JS, and all other plugins have had their chance
|
||||
// to set config.optimization. This guarantees that no Terser
|
||||
// or another minimizer plugin can re-enable itself after this point.
|
||||
//
|
||||
// ROOT CAUSE:
|
||||
// sqlite3-worker1.mjs contains a top-level `return` statement
|
||||
// that is valid for browser-worker scripts but crashes Terser.
|
||||
// Disabling minification for the entire shell module is the
|
||||
// most stable fix in the context of WASM-heavy KMP projects.
|
||||
// (Performance trade-off is negligible for WASM bundles.)
|
||||
//
|
||||
// REFERENCE: docs/99_Journal for build history.
|
||||
// ============================================================
|
||||
|
||||
console.log("[z_disable-minification] Enforcing minimize=false as FINAL webpack config step.");
|
||||
|
||||
config.optimization = config.optimization || {};
|
||||
config.optimization.minimize = false;
|
||||
config.optimization.minimizer = [];
|
||||
|
||||
console.log("[z_disable-minification] Terser and all minimizers are now DISABLED.");
|
||||
|
|
@ -35,5 +35,6 @@ dependencies {
|
|||
implementation(project(":frontend:core:network"))
|
||||
implementation(project(":frontend:core:local-db"))
|
||||
implementation(project(":frontend:core:sync"))
|
||||
implementation(project(":frontend:shells:meldestelle-portal"))
|
||||
implementation(project(":frontend:shells:meldestelle-desktop"))
|
||||
implementation(project(":frontend:features:zns-import-feature"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ include(":backend:infrastructure:persistence")
|
|||
// --- SECURITY ---
|
||||
include(":backend:infrastructure:security")
|
||||
|
||||
// --- ZNS-IMPORTER ---
|
||||
include(":backend:infrastructure:zns-importer")
|
||||
|
||||
// === BACKEND - SERVICES ===
|
||||
// --- ENTRIES (Nennungen) ---
|
||||
include(":backend:services:entries:entries-api")
|
||||
|
|
@ -99,11 +102,15 @@ include(":backend:services:persons:persons-service")
|
|||
// --- PING (Ping Service) ---
|
||||
include(":backend:services:ping:ping-service")
|
||||
|
||||
// --- ZNS-IMPORT (Asynchroner ZNS-Stammdaten-Import) ---
|
||||
include(":backend:services:zns-import:zns-import-service")
|
||||
|
||||
// ==========================================================================
|
||||
// CORE
|
||||
// ==========================================================================
|
||||
include(":core:core-domain")
|
||||
include(":core:core-utils")
|
||||
include(":core:zns-parser")
|
||||
|
||||
// ==========================================================================
|
||||
// DOCUMENTATION
|
||||
|
|
@ -127,9 +134,9 @@ include(":frontend:core:sync")
|
|||
// include(":frontend:features:members-feature")
|
||||
include(":frontend:features:ping-feature")
|
||||
include(":frontend:features:nennung-feature")
|
||||
include(":frontend:features:zns-import-feature")
|
||||
|
||||
// --- SHELLS ---
|
||||
include(":frontend:shells:meldestelle-portal")
|
||||
include(":frontend:shells:meldestelle-desktop")
|
||||
|
||||
// ==========================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user