feat(TODO): update phases for orders, billing, and calendar features
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Marked orders and billing tasks as complete in the TODO list, detailing the associated components and APIs.
- Introduced a new phase for calendar features, outlining tasks for navigation, data loading, and event management.
- Enhanced the AppDependencies to include BillingApi and MemberOrdersApi for improved billing and order management.
- Updated AppRoot and SettingsScreen to incorporate billing and orders sections, enhancing user navigation and functionality.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 00:19:30 +02:00
parent ea46a6d4f9
commit 9be5f50ede
8 changed files with 1348 additions and 7 deletions

View File

@@ -3,6 +3,7 @@ package de.tt_tagebuch.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import de.tt_tagebuch.shared.api.BillingApi
import de.tt_tagebuch.shared.api.AccidentApi
import de.tt_tagebuch.shared.api.ApiLogsApi
import de.tt_tagebuch.shared.api.ClubApprovalsApi
@@ -22,6 +23,7 @@ import de.tt_tagebuch.shared.api.MatchesApi
import de.tt_tagebuch.shared.api.MemberActivitiesApi
import de.tt_tagebuch.shared.api.MemberGroupPhotosApi
import de.tt_tagebuch.shared.api.MemberTransferConfigApi
import de.tt_tagebuch.shared.api.MemberOrdersApi
import de.tt_tagebuch.shared.api.MembersApi
import de.tt_tagebuch.shared.api.MyTischtennisApi
import de.tt_tagebuch.shared.api.OfficialTournamentsApi
@@ -105,6 +107,8 @@ class AppDependencies(context: Context) {
val memberTransferConfigApi = MemberTransferConfigApi(client)
val myTischtennisApi = MyTischtennisApi(client)
val clickTtAccountApi = ClickTtAccountApi(client)
val memberOrdersApi = MemberOrdersApi(client)
val billingApi = BillingApi(client)
val diaryManager = DiaryManager(
DiaryApi(client),

View File

@@ -209,10 +209,17 @@ private fun MainTabs(dependencies: AppDependencies) {
var selectedTab by rememberSaveable { mutableStateOf(MainTab.Home) }
var diarySelectedEntryId by remember { mutableStateOf<Int?>(null) }
var membersNestedOpen by remember { mutableStateOf(false) }
var billingOrdersSection by remember { mutableStateOf<BillingOrdersSection?>(null) }
val useWideMainNav = LocalConfiguration.current.screenWidthDp >= MAIN_NAV_RAIL_MIN_WIDTH_DP
val clubState by dependencies.clubManager.state.collectAsState()
val visibleTabs = visibleMainTabs(clubState.currentPermissions)
LaunchedEffect(selectedTab) {
if (selectedTab != MainTab.Settings) {
billingOrdersSection = null
}
}
LaunchedEffect(clubState.currentPermissions, selectedTab) {
val tabs = visibleMainTabs(clubState.currentPermissions)
if (!tabs.contains(selectedTab)) {
@@ -255,6 +262,8 @@ private fun MainTabs(dependencies: AppDependencies) {
diarySelectedEntryId = diarySelectedEntryId,
onDiarySelectedEntryId = { diarySelectedEntryId = it },
onMembersNestedOpenChange = { membersNestedOpen = it },
billingOrdersSection = billingOrdersSection,
onBillingOrdersSectionChange = { billingOrdersSection = it },
)
}
}
@@ -268,6 +277,8 @@ private fun MainTabs(dependencies: AppDependencies) {
diarySelectedEntryId = diarySelectedEntryId,
onDiarySelectedEntryId = { diarySelectedEntryId = it },
onMembersNestedOpenChange = { membersNestedOpen = it },
billingOrdersSection = billingOrdersSection,
onBillingOrdersSectionChange = { billingOrdersSection = it },
)
}
if (!isNestedDetail) {
@@ -301,11 +312,21 @@ private fun MainTabContent(
diarySelectedEntryId: Int?,
onDiarySelectedEntryId: (Int?) -> Unit,
onMembersNestedOpenChange: (Boolean) -> Unit,
billingOrdersSection: BillingOrdersSection?,
onBillingOrdersSectionChange: (BillingOrdersSection?) -> Unit,
) {
when (selectedTab) {
MainTab.Home -> HomeScreen(
dependencies = dependencies,
onOpenTab = onNavigateTab,
onOpenGlobalOrders = {
onBillingOrdersSectionChange(BillingOrdersSection.Orders)
onNavigateTab(MainTab.Settings)
},
onOpenBilling = {
onBillingOrdersSectionChange(BillingOrdersSection.Billing)
onNavigateTab(MainTab.Settings)
},
)
MainTab.Diary -> DiaryListScreen(
dependencies = dependencies,
@@ -319,7 +340,11 @@ private fun MainTabContent(
MainTab.Schedule -> ScheduleScreen(dependencies)
MainTab.Tournaments -> TournamentsScreen(dependencies)
MainTab.Stats -> TrainingStatsScreen(dependencies)
MainTab.Settings -> SettingsScreen(dependencies)
MainTab.Settings -> SettingsScreen(
dependencies = dependencies,
billingOrdersSection = billingOrdersSection,
onBillingOrdersSectionChange = onBillingOrdersSectionChange,
)
}
}
@@ -327,6 +352,8 @@ private fun MainTabContent(
private fun HomeScreen(
dependencies: AppDependencies,
onOpenTab: (MainTab) -> Unit,
onOpenGlobalOrders: () -> Unit = {},
onOpenBilling: () -> Unit = {},
) {
val clubState by dependencies.clubManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
@@ -401,6 +428,20 @@ private fun HomeScreen(
subtitle = tr("home.tileSettings", "Sprache, Session, Links"),
onClick = { onOpenTab(MainTab.Settings) },
)
HomeHubTile(
title = tr("orders.globalTitle", "Bestellungen"),
subtitle = tr("orders.globalSubtitle", "Alle Vereine"),
onClick = onOpenGlobalOrders,
)
clubState.currentPermissions?.let { p ->
if (p.canReadMembers()) {
HomeHubTile(
title = tr("billing.title", "Abrechnung"),
subtitle = tr("home.tileBilling", "PDF aus Tagebuch-Zeiten"),
onClick = onOpenBilling,
)
}
}
SectionTitle(tr("home.clubSection", "Verein"))
ErrorText(detailError)
when {
@@ -3969,10 +4010,22 @@ private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> U
}
@Composable
private fun SettingsScreen(dependencies: AppDependencies) {
private fun SettingsScreen(
dependencies: AppDependencies,
billingOrdersSection: BillingOrdersSection?,
onBillingOrdersSectionChange: (BillingOrdersSection?) -> Unit,
) {
var clubAdminSection by remember { mutableStateOf<ClubAdminSettingsSection?>(null) }
var stammdatenSection by remember { mutableStateOf<ClubStammdatenSection?>(null) }
var personalHub by remember { mutableStateOf<PersonalHubDestination?>(null) }
if (billingOrdersSection != null) {
BillingOrdersFlowScreen(
section = billingOrdersSection!!,
dependencies = dependencies,
onBack = { onBillingOrdersSectionChange(null) },
)
return
}
if (personalHub != null) {
PersonalHubFlowScreen(
destination = personalHub!!,
@@ -4085,6 +4138,17 @@ private fun SettingsScreen(dependencies: AppDependencies) {
onClick = { personalHub = PersonalHubDestination.ClickTtBrowser },
modifier = Modifier.fillMaxWidth(),
) { Text(tr("navigation.clickTtBrowser", "HTTV / click-TT")) }
SectionTitle(tr("mobile.ordersBillingSection", "Bestellungen & Abrechnung"))
TextButton(
onClick = { onBillingOrdersSectionChange(BillingOrdersSection.Orders) },
modifier = Modifier.fillMaxWidth(),
) { Text(tr("orders.globalTitle", "Bestellungen")) }
if (clubState.currentPermissions?.canReadMembers() == true) {
TextButton(
onClick = { onBillingOrdersSectionChange(BillingOrdersSection.Billing) },
modifier = Modifier.fillMaxWidth(),
) { Text(tr("billing.title", "Abrechnung")) }
}
SectionTitle(tr("mobile.legal", "Rechtliches"))
Text(
tr("mobile.legalBrowserHint", "Öffnet die Web-Oberfläche des konfigurierten Backends im Browser."),

View File

@@ -0,0 +1,949 @@
package de.tt_tagebuch.app.ui
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.app.pdf.sharePdfFile
import de.tt_tagebuch.shared.api.models.BillingCreateRunBody
import de.tt_tagebuch.shared.api.models.BillingRunDto
import de.tt_tagebuch.shared.api.models.BillingTemplateDto
import de.tt_tagebuch.shared.api.models.MemberOrderDto
import de.tt_tagebuch.shared.api.models.MemberOrderPatchBody
import de.tt_tagebuch.shared.api.models.canReadMembers
import de.tt_tagebuch.shared.api.models.canWriteMembers
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.math.max
private val HubPad = 16.dp
private val TouchMin = 48.dp
internal enum class BillingOrdersSection {
Orders,
Billing,
}
@Composable
internal fun BillingOrdersFlowScreen(
section: BillingOrdersSection,
dependencies: AppDependencies,
onBack: () -> Unit,
) {
androidx.activity.compose.BackHandler(onBack = onBack)
when (section) {
BillingOrdersSection.Orders -> GlobalOrdersScreen(dependencies, onBack)
BillingOrdersSection.Billing -> BillingClubScreen(dependencies, onBack)
}
}
private fun normalizeAmount(value: String): Double {
val p = value.replace(',', '.').trim().toDoubleOrNull() ?: return 0.0
if (!p.isFinite()) return 0.0
return max(0.0, kotlin.math.round(p * 100.0) / 100.0)
}
private fun normalizeAmount(v: Double): Double {
if (!v.isFinite()) return 0.0
return max(0.0, kotlin.math.round(v * 100.0) / 100.0)
}
private data class OrderEditRow(
val order: MemberOrderDto,
val draftItem: String,
val draftStatus: String,
val draftCost: String,
val draftPaidAmount: String,
val draftBudget: String,
val draftPaidConfirmed: Boolean,
)
private fun MemberOrderDto.toEditRow() = OrderEditRow(
order = this,
draftItem = item,
draftStatus = status,
draftCost = cost.toString(),
draftPaidAmount = paidAmount.toString(),
draftBudget = budget.toString(),
draftPaidConfirmed = paidConfirmed,
)
private fun OrderEditRow.hasChanges(): Boolean =
draftItem != (order.item) ||
draftStatus != order.status ||
normalizeAmount(draftCost) != normalizeAmount(order.cost) ||
normalizeAmount(draftPaidAmount) != normalizeAmount(order.paidAmount) ||
normalizeAmount(draftBudget) != normalizeAmount(order.budget) ||
draftPaidConfirmed != order.paidConfirmed
private fun OrderEditRow.openAmount(): Double =
max(0.0, normalizeAmount(draftCost) - normalizeAmount(draftPaidAmount))
private val orderStatuses = listOf(
"requested" to "orders.statusRequested",
"ordered" to "orders.statusOrdered",
"arrived" to "orders.statusArrived",
"handed_over" to "orders.statusHandedOver",
)
@Composable
private fun GlobalOrdersScreen(dependencies: AppDependencies, onBack: () -> Unit) {
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val scope = rememberCoroutineScope()
var loading by remember { mutableStateOf(true) }
var err by remember { mutableStateOf<String?>(null) }
var refresh by remember { mutableIntStateOf(0) }
var rows by remember { mutableStateOf<List<OrderEditRow>>(emptyList()) }
var showCompleted by remember { mutableStateOf(false) }
var search by remember { mutableStateOf("") }
var statusFilter by remember { mutableStateOf("") }
var clubFilter by remember { mutableStateOf("") }
var completionFilter by remember { mutableStateOf("") }
var statusMenu by remember { mutableStateOf(false) }
var clubMenu by remember { mutableStateOf(false) }
var completionMenu by remember { mutableStateOf(false) }
var savingIds by remember { mutableStateOf(setOf<Int>()) }
LaunchedEffect(refresh) {
loading = true
err = null
runCatching { dependencies.memberOrdersApi.listGlobal() }
.onSuccess { env ->
rows = env.orders.map { it.toEditRow() }
}
.onFailure { err = it.message }
loading = false
}
val clubs = remember(rows) {
rows.mapNotNull { it.order.clubId to (it.order.club?.name ?: "") }
.distinctBy { it.first }
.sortedBy { it.second }
}
fun statusLabel(code: String): String {
val key = orderStatuses.find { it.first == code }?.second ?: "orders.statusRequested"
return tr(key, code)
}
val filtered = remember(rows, showCompleted, search, statusFilter, clubFilter, completionFilter) {
rows.filter { r ->
val o = r.order
if (statusFilter.isNotBlank() && o.status != statusFilter) return@filter false
if (clubFilter.isNotBlank() && o.clubId?.toString() != clubFilter) return@filter false
val paid = r.draftPaidConfirmed
val handed = r.draftStatus == "handed_over"
val completed = paid && handed
if (!showCompleted && completed) return@filter false
when (completionFilter) {
"paid" -> if (!paid) return@filter false
"handed_over" -> if (!handed) return@filter false
}
if (search.isBlank()) return@filter true
val q = search.trim().lowercase()
listOf(
r.draftItem,
o.club?.name,
o.member?.firstName,
o.member?.lastName,
).filterNotNull().joinToString(" ").lowercase().contains(q)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(HubPad),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = tr("common.back", "Zurück"))
}
Column(Modifier.weight(1f)) {
Text(tr("orders.globalTitle", "Bestellungen"), style = MaterialTheme.typography.h6)
Text(
tr("orders.globalSubtitle", "Übersicht über Bestellungen in deinen Vereinen."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
}
TextButton(onClick = { refresh++ }, enabled = !loading) {
Text(tr("common.refresh", "Aktualisieren"))
}
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
Switch(checked = showCompleted, onCheckedChange = { showCompleted = it })
Text(tr("orders.showCompleted", "Erledigte anzeigen"), modifier = Modifier.padding(start = 8.dp))
}
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text(tr("orders.searchPlaceholder", "Suche")) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
singleLine = true,
)
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(Modifier.weight(1f)) {
Text(tr("orders.status", "Status"), style = MaterialTheme.typography.caption)
Box {
TextButton(onClick = { statusMenu = true }, modifier = Modifier.fillMaxWidth()) {
Text(
if (statusFilter.isBlank()) tr("orders.filterAllStatuses", "Alle Status") else statusLabel(statusFilter),
)
}
DropdownMenu(expanded = statusMenu, onDismissRequest = { statusMenu = false }) {
DropdownMenuItem(onClick = { statusFilter = ""; statusMenu = false }) {
Text(tr("orders.filterAllStatuses", "Alle Status"))
}
orderStatuses.forEach { (v, k) ->
DropdownMenuItem(onClick = { statusFilter = v; statusMenu = false }) {
Text(tr(k, v))
}
}
}
}
}
Column(Modifier.weight(1f)) {
Text(tr("orders.club", "Verein"), style = MaterialTheme.typography.caption)
Box {
TextButton(onClick = { clubMenu = true }, modifier = Modifier.fillMaxWidth()) {
Text(
if (clubFilter.isBlank()) tr("orders.filterAllClubs", "Alle Vereine")
else clubs.find { it.first.toString() == clubFilter }?.second ?: clubFilter,
)
}
DropdownMenu(expanded = clubMenu, onDismissRequest = { clubMenu = false }) {
DropdownMenuItem(onClick = { clubFilter = ""; clubMenu = false }) {
Text(tr("orders.filterAllClubs", "Alle Vereine"))
}
clubs.forEach { (id, name) ->
DropdownMenuItem(onClick = { clubFilter = id.toString(); clubMenu = false }) {
Text(name.ifBlank { id.toString() })
}
}
}
}
}
}
Row(Modifier.fillMaxWidth().padding(top = 4.dp)) {
Column(Modifier.weight(1f)) {
Text(tr("mobile.ordersCompletionFilter", "Abschluss"), style = MaterialTheme.typography.caption)
Box {
TextButton(onClick = { completionMenu = true }, modifier = Modifier.fillMaxWidth()) {
Text(
when (completionFilter) {
"paid" -> tr("orders.filterPaid", "Bezahlt")
"handed_over" -> tr("orders.filterHandedOver", "Übergeben")
else -> tr("orders.filterAllCompletionStates", "Alle")
},
)
}
DropdownMenu(expanded = completionMenu, onDismissRequest = { completionMenu = false }) {
DropdownMenuItem(onClick = { completionFilter = ""; completionMenu = false }) {
Text(tr("orders.filterAllCompletionStates", "Alle"))
}
DropdownMenuItem(onClick = { completionFilter = "paid"; completionMenu = false }) {
Text(tr("orders.filterPaid", "Bezahlt"))
}
DropdownMenuItem(onClick = { completionFilter = "handed_over"; completionMenu = false }) {
Text(tr("orders.filterHandedOver", "Übergeben"))
}
}
}
}
}
err?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) }
when {
loading -> CircularProgressIndicator(Modifier.padding(top = 24.dp))
filtered.isEmpty() -> Text(
tr("orders.noOrdersGlobal", "Keine Bestellungen."),
modifier = Modifier.padding(top = 16.dp),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
else -> filtered.forEach { row ->
val o = row.order
val mid = o.memberId ?: return@forEach
val cid = o.clubId ?: return@forEach
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
elevation = 1.dp,
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
listOf(o.club?.name, o.member?.firstName, o.member?.lastName)
.filterNotNull().joinToString(" · "),
fontWeight = FontWeight.SemiBold,
)
OutlinedTextField(
value = row.draftItem,
onValueChange = { v ->
rows = rows.map { if (it.order.id == o.id) it.copy(draftItem = v) else it }
},
label = { Text(tr("orders.item", "Artikel")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(tr("orders.status", "Status"), style = MaterialTheme.typography.caption)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
orderStatuses.forEach { (v, k) ->
TextButton(
onClick = {
rows = rows.map { if (it.order.id == o.id) it.copy(draftStatus = v) else it }
},
) {
Text(
tr(k, v),
style = if (row.draftStatus == v) MaterialTheme.typography.body2 else MaterialTheme.typography.caption,
fontWeight = if (row.draftStatus == v) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = row.draftCost,
onValueChange = { v ->
rows = rows.map { if (it.order.id == o.id) it.copy(draftCost = v) else it }
},
label = { Text(tr("orders.cost", "Kosten")) },
modifier = Modifier.weight(1f),
singleLine = true,
)
OutlinedTextField(
value = row.draftPaidAmount,
onValueChange = { v ->
rows = rows.map { if (it.order.id == o.id) it.copy(draftPaidAmount = v) else it }
},
label = { Text(tr("orders.paid", "Bezahlt")) },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = row.draftPaidConfirmed,
onCheckedChange = { v ->
rows = rows.map { if (it.order.id == o.id) it.copy(draftPaidConfirmed = v) else it }
},
)
Text(tr("orders.paidConfirmed", "Bezahlt bestätigt"), modifier = Modifier.padding(start = 8.dp))
}
OutlinedTextField(
value = row.draftBudget,
onValueChange = { v ->
rows = rows.map { if (it.order.id == o.id) it.copy(draftBudget = v) else it }
},
label = { Text(tr("orders.budget", "Budget")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
"${tr("orders.open", "Offen")}: ${String.format(java.util.Locale.GERMANY, "%.2f €", row.openAmount())}",
style = MaterialTheme.typography.caption,
)
if (o.historyEntries.isNotEmpty()) {
Text(tr("orders.history", "Verlauf") + " (${o.historyEntries.size})", fontWeight = FontWeight.Medium)
o.historyEntries.take(5).forEach { h ->
Text(
"${h.changedAt?.take(16) ?: ""} · ${statusLabel(h.status ?: "")} · " +
"${h.cost} / ${h.paidAmount}",
style = MaterialTheme.typography.caption,
)
}
}
val busy = savingIds.contains(o.id)
Button(
onClick = {
scope.launch {
savingIds = savingIds + o.id
runCatching {
dependencies.memberOrdersApi.update(
clubId = cid,
memberId = mid,
orderId = o.id,
body = MemberOrderPatchBody(
item = row.draftItem.trim(),
status = row.draftStatus,
cost = normalizeAmount(row.draftCost),
paidAmount = normalizeAmount(row.draftPaidAmount),
budget = normalizeAmount(row.draftBudget),
paidConfirmed = row.draftPaidConfirmed,
),
).order?.let { updated ->
rows = rows.map {
if (it.order.id == o.id) updated.toEditRow() else it
}
}
}.onFailure { e -> err = e.message }
savingIds = savingIds - o.id
}
},
enabled = !busy && row.hasChanges(),
modifier = Modifier.fillMaxWidth().heightIn(min = TouchMin),
) { Text(if (busy) "" else tr("common.save", "Speichern")) }
}
}
}
}
}
}
private fun monthYyyyMmOrNull(s: String): Boolean =
s.matches(Regex("\\d{4}-\\d{2}"))
private fun billingLocaleTag(languageCode: String): String =
when (languageCode.lowercase().take(2)) {
"en" -> "en-GB"
else -> "de-DE"
}
private fun runDeletable(status: String?): Boolean {
val s = status?.lowercase() ?: ""
return s == "generated" || s == "draft" || s == "error" || s == "cancelled"
}
@Composable
private fun BillingClubScreen(dependencies: AppDependencies, onBack: () -> Unit) {
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val context = LocalContext.current
val androidContext = context as Context
val scope = rememberCoroutineScope()
val clubState by dependencies.clubManager.state.collectAsState()
val authState by dependencies.authManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val perms = clubState.currentPermissions
if (perms?.canReadMembers() != true) {
Column(Modifier.fillMaxSize().padding(HubPad)) {
Text(tr("mobile.billingNoPermission", "Keine Berechtigung für Abrechnung."), color = MaterialTheme.colors.error)
TextButton(onClick = onBack) { Text(tr("common.back", "Zurück")) }
}
return
}
val canWrite = perms.canWriteMembers()
var loading by remember { mutableStateOf(true) }
var err by remember { mutableStateOf<String?>(null) }
var info by remember { mutableStateOf<String?>(null) }
var refresh by remember { mutableIntStateOf(0) }
var templates by remember { mutableStateOf<List<BillingTemplateDto>>(emptyList()) }
var runs by remember { mutableStateOf<List<BillingRunDto>>(emptyList()) }
var templateName by remember { mutableStateOf("") }
var templateDescription by remember { mutableStateOf("") }
var templatePdfBytes by remember { mutableStateOf<ByteArray?>(null) }
var templateBusy by remember { mutableStateOf(false) }
val thisMonth = remember {
java.time.LocalDate.now().toString().take(7)
}
var monthFrom by remember { mutableStateOf(thisMonth) }
var monthTo by remember { mutableStateOf(thisMonth) }
var selectedTemplateId by remember { mutableStateOf<Int?>(null) }
var selfName by remember { mutableStateOf("") }
var iban by remember { mutableStateOf("") }
var ibanWithoutCountry by remember { mutableStateOf(false) }
var hourlyRate by remember { mutableStateOf("") }
var sessionLabel by remember { mutableStateOf("") }
var locationText by remember { mutableStateOf("") }
var documentDate by remember {
mutableStateOf(java.time.LocalDate.now().toString())
}
var sameAccount by remember { mutableStateOf(false) }
var omitSelf by remember { mutableStateOf(false) }
var omitIban by remember { mutableStateOf(false) }
var omitLocation by remember { mutableStateOf(false) }
var omitDocDate by remember { mutableStateOf(false) }
var omitSession by remember { mutableStateOf(false) }
var previewTotal by remember { mutableStateOf<Double?>(null) }
var previewSessions by remember { mutableStateOf(0) }
var runBusy by remember { mutableStateOf(false) }
var generatingIds by remember { mutableStateOf(setOf<Int>()) }
var deleteRunTarget by remember { mutableStateOf<BillingRunDto?>(null) }
var deleteTemplateTarget by remember { mutableStateOf<BillingTemplateDto?>(null) }
val pickPdf = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
androidContext.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
}.onSuccess { bytes ->
templatePdfBytes = bytes
info = tr("mobile.billingPdfPicked", "PDF ausgewählt")
}.onFailure { err = it.message }
}
}
LaunchedEffect(refresh, clubId) {
loading = true
err = null
runCatching {
val t = dependencies.billingApi.listTemplates(clubId)
val r = dependencies.billingApi.listRuns(clubId)
val s = dependencies.billingApi.getSettings(clubId)
templates = t.templates
runs = r.runs
s.settings?.let { set ->
if (hourlyRate.isBlank() && set.lastHourlyRate != null) {
hourlyRate = set.lastHourlyRate.toString()
}
if (selfName.isBlank()) {
selfName = set.lastSelfRecipientName?.trim().orEmpty()
.ifBlank { authState.username.orEmpty() }
}
if (locationText.isBlank()) {
locationText = set.lastLocationText.orEmpty()
}
}
if (selfName.isBlank()) {
selfName = authState.username.orEmpty()
}
if (selectedTemplateId == null && templates.isNotEmpty()) {
selectedTemplateId = templates.first().id
}
}.onFailure { e -> err = e.message }
loading = false
}
LaunchedEffect(clubId, monthFrom, monthTo) {
if (!monthYyyyMmOrNull(monthFrom) || !monthYyyyMmOrNull(monthTo)) {
previewTotal = null
previewSessions = 0
return@LaunchedEffect
}
runCatching {
val p = dependencies.billingApi.hoursPreview(clubId, monthFrom, monthTo)
previewTotal = p.computedHoursTotal
previewSessions = p.sessions.size
}.onFailure {
previewTotal = null
previewSessions = 0
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(HubPad),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = tr("common.back", "Zurück"))
}
Column(Modifier.weight(1f)) {
Text(tr("billing.title", "Abrechnung"), style = MaterialTheme.typography.h6)
Text(
tr("billing.subtitle", "PDF-Abrechnung aus Tagebuch-Zeiten."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
}
}
OutlinedButton(
onClick = { dependencies.openBackendPath("/billing") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).heightIn(min = TouchMin),
) {
Text(tr("mobile.billingOpenWebHint", "Vollständige Abrechnung im Browser (PDF-Vorlagen-Mapping)"))
}
info?.let { Text(it, color = MaterialTheme.colors.primary, modifier = Modifier.padding(top = 8.dp)) }
err?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) }
if (loading) {
CircularProgressIndicator(Modifier.padding(top = 24.dp))
return@Column
}
Text(tr("billing.templateSection", "Vorlagen"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 16.dp))
if (canWrite) {
OutlinedTextField(
value = templateName,
onValueChange = { templateName = it },
label = { Text(tr("billing.templateName", "Name")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = templateDescription,
onValueChange = { templateDescription = it },
label = { Text(tr("billing.templateDescription", "Beschreibung")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedButton(
onClick = { pickPdf.launch("application/pdf") },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) { Text(tr("billing.templatePdf", "PDF wählen")) }
Button(
onClick = {
val bytes = templatePdfBytes ?: return@Button
val name = templateName.trim()
if (name.isEmpty()) return@Button
scope.launch {
templateBusy = true
err = null
runCatching {
dependencies.billingApi.uploadTemplate(
clubId = clubId,
name = name,
description = templateDescription.trim(),
pdfBytes = bytes,
)
templateName = ""
templateDescription = ""
templatePdfBytes = null
refresh++
info = tr("mobile.billingTemplateUploaded", "Vorlage hochgeladen")
}.onFailure { e -> err = e.message }
templateBusy = false
}
},
enabled = !templateBusy && templatePdfBytes != null && templateName.isNotBlank(),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).heightIn(min = TouchMin),
) { Text(if (templateBusy) "" else tr("billing.uploadTemplate", "Vorlage hochladen")) }
}
templates.forEach { tpl ->
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
Row(
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("${tpl.name} (v${tpl.version})", fontWeight = FontWeight.Medium)
tpl.description?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.caption) }
}
if (canWrite) {
TextButton(onClick = { deleteTemplateTarget = tpl }) {
Text(tr("billing.deleteTemplate", "Löschen"), color = MaterialTheme.colors.error)
}
}
}
}
}
if (templates.isEmpty()) {
Text(tr("billing.noTemplates", "Keine Vorlagen."), style = MaterialTheme.typography.caption)
}
Text(tr("billing.runSection", "Abrechnung erstellen"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 20.dp))
Text(tr("billing.step2", "Schritt 2"), style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.65f))
OutlinedTextField(
value = monthFrom,
onValueChange = { monthFrom = it },
label = { Text(tr("billing.monthFrom", "Monat von (YYYY-MM)")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = canWrite,
)
OutlinedTextField(
value = monthTo,
onValueChange = { monthTo = it },
label = { Text(tr("billing.monthTo", "Monat bis (YYYY-MM)")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
singleLine = true,
enabled = canWrite,
)
Text(
tr("billing.hoursAutoHint", "Stunden aus Tagebuch: {hours} h ({count} Termine)")
.replace("{hours}", previewTotal?.let { String.format(java.util.Locale.GERMANY, "%.2f", it) } ?: "")
.replace("{count}", previewSessions.toString()),
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(top = 4.dp),
)
var tplMenu by remember { mutableStateOf(false) }
Text(tr("billing.template", "Vorlage"), style = MaterialTheme.typography.caption)
Box {
TextButton(
onClick = { if (canWrite) tplMenu = true },
enabled = canWrite && templates.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) {
val id = selectedTemplateId
Text(templates.find { it.id == id }?.let { "${it.name} (v${it.version})" } ?: tr("common.choose", "Wählen"))
}
DropdownMenu(expanded = tplMenu, onDismissRequest = { tplMenu = false }) {
templates.forEach { tpl ->
DropdownMenuItem(onClick = { selectedTemplateId = tpl.id; tplMenu = false }) {
Text("${tpl.name} (v${tpl.version})")
}
}
}
}
OutlinedTextField(
value = selfName,
onValueChange = { selfName = it },
label = { Text(tr("billing.selfRecipientName", "Name / Empfänger")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
enabled = canWrite && !omitSelf,
singleLine = true,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = omitSelf, onCheckedChange = { omitSelf = it }, enabled = canWrite)
Text(tr("billing.omitField", "Feld auslassen"), modifier = Modifier.padding(start = 8.dp))
}
OutlinedTextField(
value = iban,
onValueChange = { iban = it },
label = { Text(tr("billing.iban", "IBAN")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
enabled = canWrite && !omitIban,
singleLine = true,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = ibanWithoutCountry, onCheckedChange = { ibanWithoutCountry = it }, enabled = canWrite && !omitIban)
Text(tr("billing.ibanWithoutCountry", "IBAN ohne Ländercode"), modifier = Modifier.padding(start = 8.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = omitIban,
onCheckedChange = { v ->
if (v) iban = ""
omitIban = v
},
enabled = canWrite,
)
Text(tr("billing.omitField", "IBAN auslassen"), modifier = Modifier.padding(start = 8.dp))
}
OutlinedTextField(
value = hourlyRate,
onValueChange = { hourlyRate = it },
label = { Text(tr("billing.hourlyRate", "Stundenlohn")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
enabled = canWrite,
singleLine = true,
)
OutlinedTextField(
value = sessionLabel,
onValueChange = { sessionLabel = it },
label = { Text(tr("billing.sessionLabel", "Bezeichnung Termine")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
enabled = canWrite && !omitSession,
singleLine = true,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = omitSession, onCheckedChange = { omitSession = it }, enabled = canWrite)
Text(tr("billing.omitField", "Feld auslassen"), modifier = Modifier.padding(start = 8.dp))
}
OutlinedTextField(
value = locationText,
onValueChange = { locationText = it },
label = { Text(tr("billing.locationText", "Ort")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
enabled = canWrite && !omitLocation,
singleLine = true,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = omitLocation, onCheckedChange = { omitLocation = it }, enabled = canWrite)
Text(tr("billing.omitField", "Feld auslassen"), modifier = Modifier.padding(start = 8.dp))
}
OutlinedTextField(
value = documentDate,
onValueChange = { documentDate = it },
label = { Text(tr("billing.documentDate", "Datum Dokument")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
enabled = canWrite && !omitDocDate,
singleLine = true,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = omitDocDate, onCheckedChange = { omitDocDate = it }, enabled = canWrite)
Text(tr("billing.omitField", "Feld auslassen"), modifier = Modifier.padding(start = 8.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = sameAccount, onCheckedChange = { sameAccount = it }, enabled = canWrite)
Text(tr("billing.sameAccountCheckbox", "Gleiches Konto"), modifier = Modifier.padding(start = 8.dp))
}
Button(
onClick = {
val tid = selectedTemplateId ?: return@Button
val rate = hourlyRate.replace(',', '.').toDoubleOrNull() ?: return@Button
if (!monthYyyyMmOrNull(monthFrom) || !monthYyyyMmOrNull(monthTo)) return@Button
scope.launch {
runBusy = true
err = null
info = null
runCatching {
val create = dependencies.billingApi.createRun(
clubId,
BillingCreateRunBody(
templateId = tid,
monthFrom = monthFrom,
monthTo = monthTo,
selfRecipientName = selfName.ifBlank { null },
iban = iban.ifBlank { null },
ibanWithoutCountry = ibanWithoutCountry,
hourlyRate = rate,
sessionLabel = sessionLabel.ifBlank { null },
sameAccountCheckbox = sameAccount,
omitSelfRecipientName = omitSelf,
omitIban = omitIban,
omitLocationText = omitLocation,
omitDocumentDate = omitDocDate,
omitSessionLabel = omitSession,
locationText = locationText.ifBlank { null },
documentDate = documentDate.ifBlank { null },
),
)
val runId = create.run?.id ?: error(create.error ?: "create failed")
val gen = dependencies.billingApi.generateRun(runId, billingLocaleTag(languageCode))
if (!gen.success) error(gen.error ?: "generate failed")
val pdf = dependencies.billingApi.downloadRunPdf(runId)
val f = File(androidContext.cacheDir, "abrechnung-$runId.pdf")
f.writeBytes(pdf)
sharePdfFile(androidContext, f, tr("billing.generateAndDownloadPdf", "PDF teilen"))
refresh++
info = tr("billing.generateSuccess", "Erfolgreich")
}.onFailure { e -> err = e.message }
runBusy = false
}
},
enabled = canWrite && !runBusy && selectedTemplateId != null && hourlyRate.isNotBlank() &&
monthYyyyMmOrNull(monthFrom) && monthYyyyMmOrNull(monthTo),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).heightIn(min = TouchMin),
) {
Text(if (runBusy) "" else tr("billing.generateOwnBilling", "Abrechnung erzeugen und PDF teilen"))
}
Text(tr("billing.runsTitle", "Läufe"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 20.dp))
if (runs.isEmpty()) {
Text(tr("billing.noRuns", "Keine Läufe."), style = MaterialTheme.typography.caption)
}
runs.forEach { run ->
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
Column(Modifier.padding(12.dp)) {
Text(run.selfRecipientName ?: "", fontWeight = FontWeight.Medium)
Text("${run.periodStart ?: ""} ${run.periodEnd ?: ""}", style = MaterialTheme.typography.caption)
Text(
"${tr("billing.hourlyRate", "Stundenlohn")}: ${run.hourlyRate} · ${tr("common.status", "Status")}: ${run.status ?: ""}",
style = MaterialTheme.typography.caption,
)
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val busy = generatingIds.contains(run.id)
Button(
onClick = {
scope.launch {
generatingIds = generatingIds + run.id
err = null
runCatching {
val gen = dependencies.billingApi.generateRun(run.id, billingLocaleTag(languageCode))
if (!gen.success) error(gen.error ?: "generate failed")
val pdf = dependencies.billingApi.downloadRunPdf(run.id)
val f = File(androidContext.cacheDir, "abrechnung-${run.id}.pdf")
f.writeBytes(pdf)
sharePdfFile(androidContext, f, tr("billing.generateAndDownloadPdf", "PDF teilen"))
refresh++
}.onFailure { e -> err = e.message }
generatingIds = generatingIds - run.id
}
},
enabled = !busy,
) { Text(if (busy) "" else tr("billing.generateAndDownloadPdf", "PDF")) }
if (canWrite && runDeletable(run.status)) {
TextButton(onClick = { deleteRunTarget = run }) {
Text(tr("billing.deleteBilling", "Löschen"), color = MaterialTheme.colors.error)
}
}
}
}
}
}
Spacer(Modifier.height(24.dp))
}
deleteRunTarget?.let { target ->
AlertDialog(
onDismissRequest = { deleteRunTarget = null },
title = { Text(tr("billing.deleteBilling", "Löschen")) },
text = { Text(tr("billing.deleteConfirm", "Abrechnungslauf wirklich löschen?")) },
confirmButton = {
TextButton(
onClick = {
scope.launch {
runCatching { dependencies.billingApi.deleteRun(target.id) }
.onSuccess { refresh++; deleteRunTarget = null; info = tr("billing.deleteSuccess", "Gelöscht") }
.onFailure { err = it.message }
}
},
) { Text(tr("common.delete", "Löschen")) }
},
dismissButton = { TextButton(onClick = { deleteRunTarget = null }) { Text(tr("common.cancel", "Abbrechen")) } },
)
}
deleteTemplateTarget?.let { target ->
AlertDialog(
onDismissRequest = { deleteTemplateTarget = null },
title = { Text(tr("billing.deleteTemplate", "Vorlage löschen")) },
text = { Text(tr("billing.deleteTemplateConfirm", "Vorlage wirklich löschen?")) },
confirmButton = {
TextButton(
onClick = {
scope.launch {
runCatching { dependencies.billingApi.deleteTemplate(target.id) }
.onSuccess {
deleteTemplateTarget = null
if (selectedTemplateId == target.id) selectedTemplateId = null
refresh++
info = tr("billing.deleteTemplateSuccess", "Vorlage gelöscht")
}
.onFailure { err = it.message }
}
},
) { Text(tr("common.delete", "Löschen")) }
},
dismissButton = { TextButton(onClick = { deleteTemplateTarget = null }) { Text(tr("common.cancel", "Abbrechen")) } },
)
}
}