feat(TODO): update phases for orders, billing, and calendar features
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
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:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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")) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user