feat(frontend+billing): integrate billing UI and navigation into Turnier module
- **Navigation Updates:** - Added `AppScreen.Billing` route for participant billing linked to an event and tournament. - **UI Additions:** - Introduced `BillingScreen` and `BillingViewModel` for participant account management and manual transactions. - Updated `TurnierAbrechnungTab` to include `BillingScreen` and enable account interaction. - **Turnier Enhancements:** - Enhanced `NennungenTabContent` to support navigation to billing via a new interaction. - Added billing feature as a dependency to `turnier-feature`. - **Billing Domain:** - Extended `Money` to include subtraction operation and improved formatting for negative amounts. - Added DTOs (`TeilnehmerKontoDto`, `BuchungDto`, `BuchungRequest`) for seamless data exchange with backend. - **Test Improvements:** - Updated `PreviewTurnierAbrechnungTab` to include interactive billing placeholder. - **Misc Updates:** - Enhanced breadcrumb navigation for billing in `DesktopMainLayout` for better user experience.
This commit is contained in:
+2
@@ -1,8 +1,10 @@
|
||||
package at.mocode.frontend.features.billing.di
|
||||
|
||||
import at.mocode.frontend.features.billing.domain.BillingCalculator
|
||||
import at.mocode.frontend.features.billing.presentation.BillingViewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val billingModule = module {
|
||||
single { BillingCalculator() }
|
||||
factory { BillingViewModel() }
|
||||
}
|
||||
|
||||
+47
-3
@@ -1,6 +1,7 @@
|
||||
package at.mocode.frontend.features.billing.domain
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.jvm.JvmInline
|
||||
|
||||
/**
|
||||
* Repräsentiert einen Geldbetrag in Cent zur Vermeidung von Floating-Point-Fehlern.
|
||||
@@ -9,15 +10,58 @@ import kotlinx.serialization.Serializable
|
||||
@JvmInline
|
||||
value class Money(val cents: Long) {
|
||||
operator fun plus(other: Money) = Money(this.cents + other.cents)
|
||||
operator fun minus(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)
|
||||
val negative = cents < 0
|
||||
val absCents = if (negative) -cents else cents
|
||||
val euros = absCents / 100
|
||||
val rest = absCents % 100
|
||||
return "%s%d,%02d €".format(if (negative) "-" else "", euros, rest)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO für ein Teilnehmer-Konto (analog zum Backend).
|
||||
*/
|
||||
@Serializable
|
||||
data class TeilnehmerKontoDto(
|
||||
val id: String,
|
||||
val veranstaltungId: String,
|
||||
val personId: String,
|
||||
val personName: String,
|
||||
val saldoCent: Long,
|
||||
val bemerkungen: String? = null
|
||||
) {
|
||||
val saldo: Money get() = Money(saldoCent)
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO für eine Buchung (analog zum Backend).
|
||||
*/
|
||||
@Serializable
|
||||
data class BuchungDto(
|
||||
val id: String,
|
||||
val kontoId: String,
|
||||
val betragCent: Long,
|
||||
val typ: String,
|
||||
val verwendungszweck: String,
|
||||
val gebuchtAm: String // ISO-8601 String
|
||||
) {
|
||||
val betrag: Money get() = Money(betragCent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-DTO für eine manuelle Buchung.
|
||||
*/
|
||||
@Serializable
|
||||
data class BuchungRequest(
|
||||
val betragCent: Long,
|
||||
val verwendungszweck: String,
|
||||
val typ: String = "MANUELL"
|
||||
)
|
||||
|
||||
enum class GebuehrTyp {
|
||||
NENN_GEBUEHR,
|
||||
START_GEBUEHR,
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package at.mocode.frontend.features.billing.presentation
|
||||
|
||||
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.Person
|
||||
import androidx.compose.material.icons.filled.Receipt
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.features.billing.domain.BuchungDto
|
||||
import at.mocode.frontend.features.billing.domain.TeilnehmerKontoDto
|
||||
|
||||
@Composable
|
||||
fun BillingScreen(
|
||||
viewModel: BillingViewModel,
|
||||
veranstaltungId: Long,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var showBuchungsDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(veranstaltungId) {
|
||||
viewModel.loadKonten(veranstaltungId)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// Header
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Receipt, contentDescription = null, modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Teilnehmer-Abrechnung", style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = { viewModel.loadKonten(veranstaltungId) }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Aktualisieren")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Linke Seite: Kontenliste
|
||||
Card(
|
||||
modifier = Modifier.weight(0.4f).fillMaxHeight(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Text("Teilnehmer", fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
if (state.isLoading && state.konten.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally).padding(16.dp))
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
items(state.konten) { konto ->
|
||||
KontoItem(
|
||||
konto = konto,
|
||||
isSelected = state.selectedKonto?.id == konto.id,
|
||||
onClick = { viewModel.selectKonto(konto) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
// Rechte Seite: Buchungen
|
||||
Card(
|
||||
modifier = Modifier.weight(0.6f).fillMaxHeight(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Buchungen", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (state.selectedKonto != null) {
|
||||
Button(
|
||||
onClick = { showBuchungsDialog = true },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
modifier = Modifier.height(32.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Buchen", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.selectedKonto?.let { konto ->
|
||||
Text(konto.personName, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text("Saldo: ${konto.saldo}", fontWeight = FontWeight.Bold,
|
||||
color = if (konto.saldoCent < 0) MaterialTheme.colorScheme.error else Color(0xFF388E3C))
|
||||
} ?: Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Bitte wählen Sie einen Teilnehmer aus", color = Color.Gray)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.buchungen) { buchung ->
|
||||
BuchungItem(buchung)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBuchungsDialog) {
|
||||
ManuelleBuchungDialog(
|
||||
onDismiss = { showBuchungsDialog = false },
|
||||
onConfirm = { betrag, zweck ->
|
||||
viewModel.buche(betrag, zweck)
|
||||
showBuchungsDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KontoItem(konto: TeilnehmerKontoDto, isSelected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onClick() },
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(20.dp), tint = Color.Gray)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(konto.personName, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
if (konto.bemerkungen != null) {
|
||||
Text(konto.bemerkungen, fontSize = 11.sp, color = Color.Gray, maxLines = 1)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = konto.saldo.toString(),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (konto.saldoCent < 0) MaterialTheme.colorScheme.error else Color(0xFF388E3C)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuchungItem(buchung: BuchungDto) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(buchung.verwendungszweck, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
Text(buchung.gebuchtAm.take(16).replace("T", " "), fontSize = 11.sp, color = Color.Gray)
|
||||
}
|
||||
Text(
|
||||
text = buchung.betrag.toString(),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (buchung.betragCent < 0) MaterialTheme.colorScheme.error else Color(0xFF388E3C)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(top = 4.dp), thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManuelleBuchungDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Long, String) -> Unit
|
||||
) {
|
||||
var betragStr by remember { mutableStateOf("") }
|
||||
var zweck by remember { mutableStateOf("") }
|
||||
var isGutschrift by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Manuelle Buchung") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = !isGutschrift, onClick = { isGutschrift = false })
|
||||
Text("Belastung (-)", modifier = Modifier.clickable { isGutschrift = false })
|
||||
Spacer(Modifier.width(16.dp))
|
||||
RadioButton(selected = isGutschrift, onClick = { isGutschrift = true })
|
||||
Text("Gutschrift (+)", modifier = Modifier.clickable { isGutschrift = true })
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = betragStr,
|
||||
onValueChange = { if (it.isEmpty() || it.toDoubleOrNull() != null) betragStr = it },
|
||||
label = { Text("Betrag in €") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = zweck,
|
||||
onValueChange = { zweck = it },
|
||||
label = { Text("Verwendungszweck") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val euro = betragStr.toDoubleOrNull() ?: 0.0
|
||||
val cent = (euro * 100).toLong()
|
||||
onConfirm(if (isGutschrift) cent else -cent, zweck)
|
||||
},
|
||||
enabled = betragStr.isNotEmpty() && zweck.isNotEmpty()
|
||||
) {
|
||||
Text("Buchen")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Abbrechen") }
|
||||
}
|
||||
)
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package at.mocode.frontend.features.billing.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.mocode.frontend.features.billing.domain.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
data class BillingUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val konten: List<TeilnehmerKontoDto> = emptyList(),
|
||||
val selectedKonto: TeilnehmerKontoDto? = null,
|
||||
val buchungen: List<BuchungDto> = emptyList(),
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
class BillingViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(BillingUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
fun loadKonten(veranstaltungId: Long) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
// TODO: Echter API-Call zum backend:billing-service
|
||||
// Simuliere Daten für MVP (Mock)
|
||||
val mockKonten = listOf(
|
||||
TeilnehmerKontoDto(
|
||||
id = Uuid.random().toString(),
|
||||
veranstaltungId = veranstaltungId.toString(),
|
||||
personId = Uuid.random().toString(),
|
||||
personName = "Max Mustermann",
|
||||
saldoCent = -4500L,
|
||||
bemerkungen = "Stallbox reserviert"
|
||||
),
|
||||
TeilnehmerKontoDto(
|
||||
id = Uuid.random().toString(),
|
||||
veranstaltungId = veranstaltungId.toString(),
|
||||
personId = Uuid.random().toString(),
|
||||
personName = "Erika Musterreiterin",
|
||||
saldoCent = 1250L
|
||||
)
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(konten = mockKonten, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectKonto(konto: TeilnehmerKontoDto) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(selectedKonto = konto, isLoading = true)
|
||||
// TODO: API-Call für Buchungen
|
||||
val mockBuchungen = listOf(
|
||||
BuchungDto(
|
||||
id = Uuid.random().toString(),
|
||||
kontoId = konto.id,
|
||||
betragCent = -4000L,
|
||||
typ = "NENNUNG",
|
||||
verwendungszweck = "Nenngeld Bewerb 1",
|
||||
gebuchtAm = "2026-04-10T10:00:00Z"
|
||||
),
|
||||
BuchungDto(
|
||||
id = Uuid.random().toString(),
|
||||
kontoId = konto.id,
|
||||
betragCent = -500L,
|
||||
typ = "GEBUEHR",
|
||||
verwendungszweck = "Systemgebühr",
|
||||
gebuchtAm = "2026-04-10T10:05:00Z"
|
||||
)
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(buchungen = mockBuchungen, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun buche(betragCent: Long, zweck: String) {
|
||||
val konto = _uiState.value.selectedKonto ?: return
|
||||
viewModelScope.launch {
|
||||
// TODO: API-Call POST /billing/konten/{id}/buchungen
|
||||
val neueBuchung = BuchungDto(
|
||||
id = Uuid.random().toString(),
|
||||
kontoId = konto.id,
|
||||
betragCent = betragCent,
|
||||
typ = "MANUELL",
|
||||
verwendungszweck = zweck,
|
||||
gebuchtAm = "2026-04-10T13:00:00Z"
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
buchungen = listOf(neueBuchung) + _uiState.value.buchungen,
|
||||
selectedKonto = konto.copy(saldoCent = konto.saldoCent + betragCent)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user