From 9be5f50edebf95e2915a9e2a91d1564ad2613624 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 13 May 2026 00:19:30 +0200 Subject: [PATCH] feat(TODO): update phases for orders, billing, and calendar features - 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. --- mobile-app/TODO.md | 26 +- .../de/tt_tagebuch/app/AppDependencies.kt | 4 + .../kotlin/de/tt_tagebuch/app/ui/AppRoot.kt | 68 +- .../app/ui/BillingOrdersScreens.kt | 949 ++++++++++++++++++ .../de/tt_tagebuch/shared/api/BillingApi.kt | 88 ++ .../tt_tagebuch/shared/api/MemberOrdersApi.kt | 36 + .../shared/api/models/BillingDtos.kt | 104 ++ .../shared/api/models/MemberOrderDtos.kt | 80 ++ 8 files changed, 1348 insertions(+), 7 deletions(-) create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/BillingOrdersScreens.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/BillingApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MemberOrdersApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/BillingDtos.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberOrderDtos.kt diff --git a/mobile-app/TODO.md b/mobile-app/TODO.md index 3505da65..c140a45a 100644 --- a/mobile-app/TODO.md +++ b/mobile-app/TODO.md @@ -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. diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt index 64948967..0ad6e68f 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt @@ -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), diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt index e6def419..55f7c2bf 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt @@ -209,10 +209,17 @@ private fun MainTabs(dependencies: AppDependencies) { var selectedTab by rememberSaveable { mutableStateOf(MainTab.Home) } var diarySelectedEntryId by remember { mutableStateOf(null) } var membersNestedOpen by remember { mutableStateOf(false) } + var billingOrdersSection by remember { mutableStateOf(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(null) } var stammdatenSection by remember { mutableStateOf(null) } var personalHub by remember { mutableStateOf(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."), diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/BillingOrdersScreens.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/BillingOrdersScreens.kt new file mode 100644 index 00000000..e8500252 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/BillingOrdersScreens.kt @@ -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(null) } + var refresh by remember { mutableIntStateOf(0) } + var rows by remember { mutableStateOf>(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()) } + + 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(null) } + var info by remember { mutableStateOf(null) } + var refresh by remember { mutableIntStateOf(0) } + var templates by remember { mutableStateOf>(emptyList()) } + var runs by remember { mutableStateOf>(emptyList()) } + + var templateName by remember { mutableStateOf("") } + var templateDescription by remember { mutableStateOf("") } + var templatePdfBytes by remember { mutableStateOf(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(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(null) } + var previewSessions by remember { mutableStateOf(0) } + var runBusy by remember { mutableStateOf(false) } + var generatingIds by remember { mutableStateOf(setOf()) } + var deleteRunTarget by remember { mutableStateOf(null) } + var deleteTemplateTarget by remember { mutableStateOf(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")) } }, + ) + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/BillingApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/BillingApi.kt new file mode 100644 index 00000000..178eba82 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/BillingApi.kt @@ -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") + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MemberOrdersApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MemberOrdersApi.kt new file mode 100644 index 00000000..6f6f4ad8 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MemberOrdersApi.kt @@ -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() +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/BillingDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/BillingDtos.kt new file mode 100644 index 00000000..9f9a0253 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/BillingDtos.kt @@ -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 = 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 = 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 = 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, +) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberOrderDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberOrderDtos.kt new file mode 100644 index 00000000..d7482d17 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberOrderDtos.kt @@ -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 = emptyList(), +) + +@Serializable +data class MemberOrdersListEnvelope( + val success: Boolean = false, + val orders: List = 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, +)