diff --git a/frontend/features/billing-feature/build.gradle.kts b/frontend/features/billing-feature/build.gradle.kts new file mode 100644 index 00000000..8ec73270 --- /dev/null +++ b/frontend/features/billing-feature/build.gradle.kts @@ -0,0 +1,47 @@ +/** + * Dieses Modul kapselt die Gebühren-Logik und Abrechnungs-Features (Billing-Sync). + */ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) +} + +group = "at.mocode.clients" +version = "1.0.0" + +kotlin { + jvm() + + sourceSets { + commonMain.dependencies { + implementation(projects.frontend.core.designSystem) + implementation(projects.frontend.core.network) + implementation(projects.frontend.core.domain) + implementation(projects.core.coreDomain) + + implementation(compose.foundation) + implementation(compose.runtime) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.materialIconsExtended) + + implementation(libs.bundles.kmp.common) + implementation(libs.bundles.compose.common) + + implementation(libs.koin.core) + implementation(libs.koin.compose) + } + + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + + jvmMain.dependencies { + implementation(compose.uiTooling) + } + } +} diff --git a/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/di/BillingModule.kt b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/di/BillingModule.kt new file mode 100644 index 00000000..68219efc --- /dev/null +++ b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/di/BillingModule.kt @@ -0,0 +1,8 @@ +package at.mocode.frontend.features.billing.di + +import at.mocode.frontend.features.billing.domain.BillingCalculator +import org.koin.dsl.module + +val billingModule = module { + single { BillingCalculator() } +} diff --git a/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/domain/BillingModels.kt b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/domain/BillingModels.kt new file mode 100644 index 00000000..21e78e90 --- /dev/null +++ b/frontend/features/billing-feature/src/commonMain/kotlin/at/mocode/frontend/features/billing/domain/BillingModels.kt @@ -0,0 +1,58 @@ +package at.mocode.frontend.features.billing.domain + +import kotlinx.serialization.Serializable + +/** + * Repräsentiert einen Geldbetrag in Cent zur Vermeidung von Floating-Point-Fehlern. + */ +@Serializable +@JvmInline +value class Money(val cents: Long) { + operator fun plus(other: Money) = Money(this.cents + other.cents) + operator fun times(factor: Int) = Money(this.cents * factor) + + override fun toString(): String { + val euros = cents / 100 + val rest = cents % 100 + return "%d,%02d €".format(euros, if (rest < 0) -rest else rest) + } +} + +enum class GebuehrTyp { + NENN_GEBUEHR, + START_GEBUEHR, + SPORTFOERDERBEITRAG, + BOXEN_GEBUEHR, + SYSTEM_GEBUEHR, + SONSTIGES +} + +/** + * Logik für die Gebührenberechnung gemäß ÖTO. + */ +class BillingCalculator { + /** + * Berechnet die Gesamtsumme für eine Nennung. + * @param basisNenngeld Das Nenngeld des Bewerbs. + * @param sportfoerderbeitrag Der Sportförderbeitrag (standardmäßig 1,00 € gemäß ÖTO). + */ + fun berechneNennsumme( + basisNenngeld: Money, + sportfoerderbeitrag: Money = Money(100) + ): Money { + return basisNenngeld + sportfoerderbeitrag + } + + /** + * Berechnet das Preisgeld gemäß § 30 ÖTO (Verteilungsschlüssel). + * Einfache Implementierung für den Anfang. + */ + fun berechnePreisgeld(gesamtSumme: Money, platzierung: Int): Money { + return when (platzierung) { + 1 -> Money((gesamtSumme.cents * 0.25).toLong()) + 2 -> Money((gesamtSumme.cents * 0.20).toLong()) + 3 -> Money((gesamtSumme.cents * 0.15).toLong()) + else -> Money(0) + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt new file mode 100644 index 00000000..ad4b9282 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierWizardV2.kt @@ -0,0 +1,152 @@ +package at.mocode.turnier.feature.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import at.mocode.frontend.core.designsystem.models.PlaceholderContent + +private val PrimaryBlue = Color(0xFF1E3A8A) + +/** + * TurnierWizardV2 - Der neue Wizard für die Turnieranlage (Vision_03). + */ +@Composable +fun TurnierWizardV2( + veranstaltungId: Long, + onBack: () -> Unit, + onSave: () -> Unit, +) { + var currentStep by remember { mutableIntStateOf(0) } + val steps = listOf("Stammdaten", "Organisation", "Bewerbe ⭐", "Preisliste") + + Column(modifier = Modifier.fillMaxSize().background(Color.White)) { + // Wizard Header + Surface(shadowElevation = 4.dp, color = Color.White) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, null) } + Spacer(Modifier.width(8.dp)) + Text("Neues Turnier anlegen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + } + Button( + onClick = onSave, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + enabled = currentStep == steps.size - 1 + ) { + Text("Turnier finalisieren") + } + } + + Spacer(Modifier.height(16.dp)) + + // Stepper UI + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + steps.forEachIndexed { index, title -> + StepItem( + title = title, + isActive = index == currentStep, + isCompleted = index < currentStep, + modifier = Modifier.weight(1f) + ) + if (index < steps.size - 1) { + Box( + modifier = Modifier.height(2.dp).weight(0.5f) + .background(if (index < currentStep) PrimaryBlue else Color.LightGray) + ) + } + } + } + } + } + + // Content Area + Box(modifier = Modifier.weight(1f).padding(32.dp)) { + when (currentStep) { + 0 -> PlaceholderContent("Stammdaten", "Hier werden OEPS-Nummer, Kategorie und Sparte konfiguriert.") + 1 -> PlaceholderContent("Organisation", "Zuweisung von Richtern, Parcourschefs und Tierärzten.") + 2 -> PlaceholderContent("Bewerbe", "Konfiguration der Bewerbe und Abteilungen gemäß § 39 ÖTO.") + 3 -> PlaceholderContent("Preisliste", "Einstellung der Nenngebühren und Sportförderbeiträge (Billing-Sync).") + } + } + + // Wizard Navigation + Surface(shadowElevation = 8.dp, color = Color.White) { + Row( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + onClick = { if (currentStep > 0) currentStep-- else onBack() } + ) { + Text(if (currentStep == 0) "Abbrechen" else "Zurück") + } + + if (currentStep < steps.size - 1) { + Button( + onClick = { currentStep++ }, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) + ) { + Text("Weiter") + Spacer(Modifier.width(8.dp)) + Icon(Icons.Default.ArrowForward, null, modifier = Modifier.size(16.dp)) + } + } + } + } + } +} + +@Composable +private fun StepItem(title: String, isActive: Boolean, isCompleted: Boolean, modifier: Modifier = Modifier) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { + Box( + modifier = Modifier + .size(32.dp) + .background( + color = when { + isCompleted -> PrimaryBlue + isActive -> PrimaryBlue + else -> Color.LightGray + }, + shape = androidx.compose.foundation.shape.CircleShape + ), + contentAlignment = Alignment.Center + ) { + if (isCompleted) { + Icon(Icons.Default.Check, null, tint = Color.White, modifier = Modifier.size(16.dp)) + } else { + Text( + text = "", // Or step number + color = Color.White, + style = MaterialTheme.typography.bodySmall + ) + } + } + Text( + text = title, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + color = if (isActive || isCompleted) PrimaryBlue else Color.Gray, + modifier = Modifier.padding(top = 4.dp) + ) + } +} diff --git a/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterAuswahlV2.kt b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterAuswahlV2.kt new file mode 100644 index 00000000..f0be90cf --- /dev/null +++ b/frontend/features/veranstalter-feature/src/jvmMain/kotlin/at/mocode/veranstalter/feature/presentation/VeranstalterAuswahlV2.kt @@ -0,0 +1,183 @@ +package at.mocode.veranstalter.feature.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import at.mocode.frontend.core.designsystem.models.LoginStatus +import at.mocode.frontend.core.designsystem.models.LoginStatusBadge + +private val PrimaryBlue = Color(0xFF1E3A8A) + +/** + * Screen: "Admin - Verwaltung / Veranstalter auswählen V2" + * Optimiert für Vision_03 mit verbesserter UI und echtem DDD-Mapping Vorbereitung. + */ +@Composable +fun VeranstalterAuswahlV2( + onZurueck: () -> Unit, + onWeiter: (Long) -> Unit, + onNeuerVeranstalter: () -> Unit = {}, +) { + var selectedId by remember { mutableStateOf(null) } + var suchtext by remember { mutableStateOf("") } + + // Placeholder-Daten gemäß Figma + val veranstalter = remember { + listOf( + VeranstalterUiModel( + 1L, + "Reit- und Fahrverein Wels", + "V-OOE-1234", + "4600 Wels", + "Maria Huber", + "office@rfv-wels.at", + LoginStatus.AKTIV + ), + VeranstalterUiModel( + 2L, + "Pferdesportverein Linz", + "V-OOE-5678", + "4020 Linz", + "Thomas Maier", + "kontakt@psv-linz.at", + LoginStatus.AKTIV + ), + VeranstalterUiModel( + 3L, + "Reitclub Eferding", + "V-OOE-9012", + "4070 Eferding", + "Anna Schmid", + "info@rc-eferding.at", + LoginStatus.AUSSTEHEND + ), + ) + } + + val gefiltert = veranstalter.filter { + suchtext.isBlank() || + it.name.contains(suchtext, ignoreCase = true) || + it.oepsNummer.contains(suchtext, ignoreCase = true) || + it.ort.contains(suchtext, ignoreCase = true) + } + + Column(modifier = Modifier.fillMaxSize().background(Color.White)) { + // Top Bar + Surface(shadowElevation = 4.dp, color = Color.White) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text("Veranstalter auswählen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text( + "Wählen Sie den Verein für die Veranstaltung aus.", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + } + IconButton(onClick = onZurueck) { + Icon(Icons.Default.Close, contentDescription = "Schließen") + } + } + } + + // Suche & Aktionen + Row( + modifier = Modifier.fillMaxWidth().padding(24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = suchtext, + onValueChange = { suchtext = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Suche nach Name, OEPS-Nummer oder Ort...") }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + singleLine = true + ) + Button( + onClick = onNeuerVeranstalter, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + shape = MaterialTheme.shapes.medium + ) { + Icon(Icons.Default.Add, null) + Spacer(Modifier.width(8.dp)) + Text("Neuer Veranstalter") + } + } + + // Liste + LazyColumn( + modifier = Modifier.weight(1f).padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(gefiltert) { item -> + val isSelected = selectedId == item.id + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { selectedId = item.id } + .border( + width = 2.dp, + color = if (isSelected) PrimaryBlue else Color.Transparent, + shape = MaterialTheme.shapes.medium + ), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) Color(0xFFEFF6FF) else Color(0xFFF9FAFB) + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = isSelected, onClick = { selectedId = item.id }) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text(item.name, fontWeight = FontWeight.Bold) + Text("${item.oepsNummer} | ${item.ort}", style = MaterialTheme.typography.bodySmall) + } + LoginStatusBadge(item.loginStatus) + } + } + } + } + + // Footer + Surface(shadowElevation = 8.dp, color = Color.White) { + Row( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onZurueck) { Text("Abbrechen") } + Spacer(Modifier.width(16.dp)) + Button( + onClick = { selectedId?.let { onWeiter(it) } }, + enabled = selectedId != null, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) + ) { + Text("Weiter zur Turnier-Konfiguration") + Spacer(Modifier.width(8.dp)) + Icon(Icons.Default.ArrowForward, null, modifier = Modifier.size(16.dp)) + } + } + } + } +} diff --git a/frontend/shells/meldestelle-desktop/build.gradle.kts b/frontend/shells/meldestelle-desktop/build.gradle.kts index c2abeccc..f9c00a1a 100644 --- a/frontend/shells/meldestelle-desktop/build.gradle.kts +++ b/frontend/shells/meldestelle-desktop/build.gradle.kts @@ -35,6 +35,7 @@ kotlin { implementation(projects.frontend.features.veranstaltungFeature) implementation(projects.frontend.features.turnierFeature) implementation(project(":frontend:features:profile-feature")) + implementation(project(":frontend:features:billing-feature")) // Compose Desktop implementation(compose.desktop.currentOs) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt index a18e7da1..bc7d9af0 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt @@ -11,6 +11,7 @@ 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.frontend.features.billing.di.billingModule import at.mocode.frontend.features.profile.di.profileModule import at.mocode.nennung.feature.di.nennungFeatureModule import at.mocode.ping.feature.di.pingFeatureModule @@ -33,6 +34,7 @@ fun main() = application { nennungFeatureModule, znsImportModule, profileModule, + billingModule, desktopModule, ) } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt index 881e9cd0..580ae446 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt @@ -23,8 +23,10 @@ import at.mocode.frontend.features.profile.presentation.ProfileViewModel import at.mocode.ping.feature.presentation.PingScreen import at.mocode.ping.feature.presentation.PingViewModel import at.mocode.turnier.feature.presentation.TurnierDetailScreen +import at.mocode.turnier.feature.presentation.TurnierWizardV2 import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore import at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore +import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlV2 import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen @@ -300,18 +302,18 @@ private fun DesktopContentArea( // Root-Screen: Leitet in V2-Fluss ab is AppScreen.Veranstaltungen -> { // Direkt zur Veranstalter-Auswahl V2 - at.mocode.desktop.v2.VeranstalterAuswahlV2( - onBack = { /* bleibt root */ }, + VeranstalterAuswahlV2( + onZurueck = { /* bleibt root */ }, onWeiter = { vId -> onNavigate(AppScreen.VeranstalterDetail(vId)) }, - onNeu = { onNavigate(AppScreen.VeranstalterNeu) }, + onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) }, ) } // Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht - is AppScreen.VeranstalterAuswahl -> at.mocode.desktop.v2.VeranstalterAuswahlV2( - onBack = { onNavigate(AppScreen.Veranstaltungen) }, + is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahlV2( + onZurueck = { onNavigate(AppScreen.Veranstaltungen) }, onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) }, - onNeu = { onNavigate(AppScreen.VeranstalterNeu) }, + onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) }, ) is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen( @@ -414,11 +416,10 @@ private fun DesktopContentArea( onBack = { onNavigate(AppScreen.Veranstaltungen) } ) } else { - at.mocode.desktop.v2.TurnierWizardV2( - veranstalterId = parent.id, + TurnierWizardV2( veranstaltungId = evtId, onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) }, - onSaved = { _ -> onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) }, + onSave = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) }, ) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1470b8b1..191a106e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -127,6 +127,7 @@ include(":frontend:features:veranstalter-feature") include(":frontend:features:veranstaltung-feature") include(":frontend:features:profile-feature") include(":frontend:features:turnier-feature") +include(":frontend:features:billing-feature") // --- SHELLS --- include(":frontend:shells:meldestelle-desktop")