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

@@ -189,13 +189,29 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug in Web nach `apiClien
## Phase 11 Abrechnung & Bestellungen
- [ ] **Orders** (`OrdersView.vue`)
- [ ] **Billing** (`BillingView.vue`)
- [ ] Rechtliches/UX: ggf. WebView oder Deep-Link, wenn Zahlungsflüsse web-only
- [x] **Orders** (`OrdersView.vue` / `OrdersPanel.vue` global) `MemberOrdersApi`, `MemberOrderDtos.kt`, `BillingOrdersScreens.kt` (`GlobalOrdersScreen`)
- [x] **Billing** (`BillingView.vue`) Kernfluss ohne Web-PDF-Mapping-Editor `BillingApi`, `BillingDtos.kt`, `BillingClubScreen` (Vorlagen-Upload, Vorschau, Lauf anlegen, PDF erzeugen/teilen, Läufe); Mapping weiter über Browser
- [x] Rechtliches/UX: Button **Abrechnung im Browser** (`/billing`) für volle Web-Funktion; PDF-Teilen wie Tagebuch-PDF
---
## Phase 12 Qualität, Tests, Release
## Phase 12 Kalender (Vereinskalender)
Web-Route: `/calendar` · Referenz: `CalendarView.vue` (Aggregation mehrerer Datenquellen in einer Monatsansicht).
- [ ] **Navigation & Shell:** Tab oder Hub-Eintrag (Berechtigungen klären), Monat vor/zurück, „Heute“, Monatsüberschrift
- [ ] **Monatsgitter:** 7×6 wie Web, Kennzeichnung „außerhalb Monat“ / heute, Wochentagskopf
- [ ] **Legende / Filter:** Eventtypen einblendbar (Training, Turnier, Teilnahme offiziell, Punktspiel, Feiertag, Ferien, Trainingsausfall) inkl. Zähler
- [ ] **Daten laden (parallel, teilfehlertolerant):** gleiche Quellen wie Web u. a. Tagebuch-Trainingstage, `GET /api/training-times/:clubId`, Trainingsausfälle, Vereins-Turniere, offizielle Teilnahmen, Punktspiele (`MatchesApi`/League-Endpoints), Feiertage/Ferien (Kalenderregion Verein); Fehler pro Quelle wie `sourceWarnings` optional anzeigen
- [ ] **Logik:** wiederkehrende Trainingszeiten expandieren, Zusammenführen von Slots (`mergeRecurringTrainingSlots`), Ausfälle blenden wiederkehrende Slots aus Verhalten an Web angleichen
- [ ] **Trainingsausfall:** Formular (Datum, optional Bis, Grund), Liste im Monat, Speichern/Löschen API wie Web (`CalendarView` / Backend-Routen zu `training_cancellations` verifizieren)
- [ ] **Agenda:** sortierte Liste „Termine im Monat“ unter dem Grid
- [ ] **Event-Aktion:** Klick → sinnvolle Ziel-Navigation (Tagebuch-Tag, Turnier-Detail, Spiel-Detail, externer Link o. ä.) wie `openEvent` im Web
- [ ] **i18n:** Texte über `MobileStrings` / Keys abstimmen mit Web-`$t` wo sinnvoll
---
## Phase 13 Qualität, Tests, Release
- [ ] **Regression-Checkliste** pro Phase (manuell)
- [ ] Automatisierte Tests: `shared` (Serialisierung, Mapper), wo möglich UI-Tests kritische Flows
@@ -215,4 +231,4 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug in Web nach `apiClien
## Referenz: Web-Routen (Router)
Siehe `frontend/src/router.js` jede `path`-Zeile sollte langfristig einem mobilen Eintrag (oder einer bewussten Ausnahme) zugeordnet sein.
Siehe `frontend/src/router.js` jede `path`-Zeile sollte langfristig einem mobilen Eintrag (oder einer bewussten Ausnahme) zugeordnet sein. **Kalender:** `/calendar` → Phase 12.

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")) } },
)
}
}

View File

@@ -0,0 +1,88 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.BillingCreateRunBody
import de.tt_tagebuch.shared.api.models.BillingCreateRunEnvelope
import de.tt_tagebuch.shared.api.models.BillingGenerateBody
import de.tt_tagebuch.shared.api.models.BillingGenerateEnvelope
import de.tt_tagebuch.shared.api.models.BillingHoursPreviewEnvelope
import de.tt_tagebuch.shared.api.models.BillingRunsEnvelope
import de.tt_tagebuch.shared.api.models.BillingSettingsEnvelope
import de.tt_tagebuch.shared.api.models.BillingTemplatesEnvelope
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.forms.MultiPartFormDataContent
import io.ktor.client.request.forms.formData
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.readBytes
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
class BillingApi(
private val client: AuthedHttpClient,
) {
suspend fun listTemplates(clubId: Int): BillingTemplatesEnvelope =
client.http.get("/api/billing/templates/$clubId").body()
suspend fun uploadTemplate(clubId: Int, name: String, description: String, pdfBytes: ByteArray, filename: String = "template.pdf") {
client.http.post("/api/billing/templates/$clubId") {
contentType(ContentType.MultiPart.FormData)
setBody(
MultiPartFormDataContent(
formData {
append("name", name)
append("description", description)
append(
"templatePdf",
pdfBytes,
Headers.build {
append(HttpHeaders.ContentType, "application/pdf")
append(HttpHeaders.ContentDisposition, "filename=\"$filename\"")
},
)
},
),
)
}
}
suspend fun deleteTemplate(templateId: Int) {
client.http.delete("/api/billing/templates/$templateId")
}
suspend fun listRuns(clubId: Int): BillingRunsEnvelope =
client.http.get("/api/billing/runs/$clubId").body()
suspend fun getSettings(clubId: Int): BillingSettingsEnvelope =
client.http.get("/api/billing/settings/$clubId").body()
suspend fun hoursPreview(clubId: Int, monthFrom: String, monthTo: String): BillingHoursPreviewEnvelope =
client.http.get("/api/billing/hours-preview/$clubId") {
parameter("monthFrom", monthFrom)
parameter("monthTo", monthTo)
}.body()
suspend fun createRun(clubId: Int, body: BillingCreateRunBody): BillingCreateRunEnvelope =
client.http.post("/api/billing/runs/$clubId") {
contentType(ContentType.Application.Json)
setBody(body)
}.body()
suspend fun generateRun(runId: Int, locale: String = "de-DE"): BillingGenerateEnvelope =
client.http.post("/api/billing/runs/$runId/generate") {
contentType(ContentType.Application.Json)
setBody(BillingGenerateBody(locale = locale))
}.body()
suspend fun downloadRunPdf(runId: Int): ByteArray =
client.http.get("/api/billing/runs/$runId/download").readBytes()
suspend fun deleteRun(runId: Int) {
client.http.delete("/api/billing/runs/$runId")
}
}

View File

@@ -0,0 +1,36 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.MemberOrderCreateBody
import de.tt_tagebuch.shared.api.models.MemberOrderEnvelope
import de.tt_tagebuch.shared.api.models.MemberOrderPatchBody
import de.tt_tagebuch.shared.api.models.MemberOrdersListEnvelope
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
class MemberOrdersApi(
private val client: AuthedHttpClient,
) {
suspend fun listGlobal(): MemberOrdersListEnvelope =
client.http.get("/api/member-orders/global").body()
suspend fun listForMember(clubId: Int, memberId: Int): MemberOrdersListEnvelope =
client.http.get("/api/member-orders/$clubId/$memberId").body()
suspend fun create(clubId: Int, memberId: Int, body: MemberOrderCreateBody): MemberOrderEnvelope =
client.http.post("/api/member-orders/$clubId/$memberId") {
contentType(ContentType.Application.Json)
setBody(body)
}.body()
suspend fun update(clubId: Int, memberId: Int, orderId: Int, body: MemberOrderPatchBody): MemberOrderEnvelope =
client.http.patch("/api/member-orders/$clubId/$memberId/$orderId") {
contentType(ContentType.Application.Json)
setBody(body)
}.body()
}

View File

@@ -0,0 +1,104 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class BillingTemplateDto(
val id: Int,
val clubId: Int? = null,
val name: String = "",
val description: String? = null,
val version: Int = 1,
)
@Serializable
data class BillingTemplatesEnvelope(
val success: Boolean = false,
val templates: List<BillingTemplateDto> = emptyList(),
)
@Serializable
data class BillingRunDto(
val id: Int,
val clubId: Int? = null,
val templateId: Int? = null,
val name: String? = null,
val periodStart: String? = null,
val periodEnd: String? = null,
val selfRecipientName: String? = null,
val hourlyRate: Double = 0.0,
val computedHoursTotal: Double? = null,
val status: String? = null,
)
@Serializable
data class BillingRunsEnvelope(
val success: Boolean = false,
val runs: List<BillingRunDto> = emptyList(),
)
@Serializable
data class BillingUserSettingsDto(
val lastHourlyRate: Double? = null,
val lastSelfRecipientName: String? = null,
val lastLocationText: String? = null,
)
@Serializable
data class BillingSettingsEnvelope(
val success: Boolean = false,
val settings: BillingUserSettingsDto? = null,
)
@Serializable
data class BillingSessionPreviewDto(
val date: String? = null,
val startTime: String? = null,
val endTime: String? = null,
val durationHours: Double? = null,
)
@Serializable
data class BillingHoursPreviewEnvelope(
val success: Boolean = false,
val computedHoursTotal: Double? = null,
val sessions: List<BillingSessionPreviewDto> = emptyList(),
)
@Serializable
data class BillingCreateRunBody(
val templateId: Int,
val monthFrom: String,
val monthTo: String,
val selfRecipientName: String? = null,
val iban: String? = null,
val ibanWithoutCountry: Boolean = false,
val hourlyRate: Double,
val sessionLabel: String? = null,
val sameAccountCheckbox: Boolean = false,
val omitSelfRecipientName: Boolean = false,
val omitIban: Boolean = false,
val omitLocationText: Boolean = false,
val omitDocumentDate: Boolean = false,
val omitSessionLabel: Boolean = false,
val locationText: String? = null,
val documentDate: String? = null,
)
@Serializable
data class BillingCreateRunEnvelope(
val success: Boolean = false,
val run: BillingRunDto? = null,
val error: String? = null,
)
@Serializable
data class BillingGenerateBody(
val locale: String = "de-DE",
)
@Serializable
data class BillingGenerateEnvelope(
val success: Boolean = false,
val error: String? = null,
)

View File

@@ -0,0 +1,80 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class OrderMemberSnippetDto(
val id: Int? = null,
val firstName: String? = null,
val lastName: String? = null,
)
@Serializable
data class OrderClubSnippetDto(
val id: Int? = null,
val name: String? = null,
)
@Serializable
data class MemberOrderHistoryEntryDto(
val id: Int? = null,
val status: String? = null,
val changedAt: String? = null,
val cost: Double? = null,
val paidAmount: Double? = null,
val budget: Double? = null,
val paidConfirmed: Boolean? = null,
)
@Serializable
data class MemberOrderDto(
val id: Int,
val memberId: Int? = null,
val clubId: Int? = null,
val item: String = "",
val status: String = "requested",
val orderDate: String? = null,
val statusDate: String? = null,
val createdAt: String? = null,
val updatedAt: String? = null,
val cost: Double = 0.0,
val paidAmount: Double = 0.0,
val budget: Double = 0.0,
val paidConfirmed: Boolean = false,
val openAmount: Double = 0.0,
val member: OrderMemberSnippetDto? = null,
val club: OrderClubSnippetDto? = null,
val historyEntries: List<MemberOrderHistoryEntryDto> = emptyList(),
)
@Serializable
data class MemberOrdersListEnvelope(
val success: Boolean = false,
val orders: List<MemberOrderDto> = emptyList(),
)
@Serializable
data class MemberOrderEnvelope(
val success: Boolean = false,
val order: MemberOrderDto? = null,
)
@Serializable
data class MemberOrderCreateBody(
val item: String,
val status: String = "requested",
val cost: Double = 0.0,
val paidAmount: Double = 0.0,
val budget: Double = 0.0,
val paidConfirmed: Boolean = false,
)
@Serializable
data class MemberOrderPatchBody(
val item: String,
val status: String,
val cost: Double,
val paidAmount: Double,
val budget: Double,
val paidConfirmed: Boolean,
)