feat(billing-feature): introduce billing module with Money class, calculation logic, and DI setup

- Added `Money` value class for precise monetary operations.
- Implemented `BillingCalculator` to handle fee calculations, including ÖTO-compliant contributions and prize distribution rules.
- Created `BillingModule` for dependency injection using Koin.
- Integrated `billing-feature` into the desktop shell and project dependencies.
- Introduced `TurnierWizardV2` and `VeranstalterAuswahlV2` screens with improved UI and billing synchronization support.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-30 16:38:25 +02:00
parent 0503cf8bcc
commit b2e6158328
9 changed files with 462 additions and 9 deletions
@@ -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<Long?>(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))
}
}
}
}
}