feat(ClubSettings): add country and state code fields for regional calendar data
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Introduced `countryCode` and `stateCode` fields in the Club model to support regional calendar data. - Updated ClubSettings component to allow users to select their country and state, enhancing the configuration options for clubs. - Enhanced the ClubService to handle normalization of country and state codes during updates. - Added new routes and middleware to support the training cancellation feature and calendar integration in the backend. - Updated frontend navigation to include a calendar link, improving user access to scheduling features.
This commit is contained in:
@@ -140,10 +140,15 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug – in Web nach `apiClien
|
||||
- [x] **i18n-Local:** `LanguageLocals.kt` (`LocalLanguageCode`) aus `AppRoot.kt` ausgelagert
|
||||
- [x] **Hinweis:** Web hat keinen CSV-Export für diese Statistik; mobil zusätzlich **CSV exportieren** (gefilterte/sortierte Mitgliederliste)
|
||||
|
||||
## Phase 6 – Terminplan (ScheduleView)
|
||||
## Phase 6 – Terminplan (ScheduleView) — erledigt
|
||||
|
||||
- [ ] Kalender-/Listenansicht, CRUD oder Sync wie Web
|
||||
- [ ] API-Endpunkte aus `ScheduleView.vue` ins `shared` übernehmen
|
||||
- [x] **DTOs:** `Schedule.kt` – `ClubTeamDto`, `ScheduleMatchDto`, `LeagueTableRowDto`, `UpdateMatchPlayersBody`, `ScheduleMatchScope`, `ScheduleViewMode`
|
||||
- [x] **APIs:** `ClubTeamsApi` (`GET /api/club-teams/club/:clubId`), `MatchesApi` (`/api/matches/leagues/...` matches + Tabelle, `PATCH /api/matches/:matchId/players`)
|
||||
- [x] **Logik:** `ScheduleLogic.kt` – Sortierung, Merge, Filter „Erwachsene“, Mannschafts-Scope wie Web
|
||||
- [x] **State:** `ScheduleManager.kt` – Mannschaften laden, Mannschafts-/Gesamt-/Erwachsenen-Ansicht, Tabelle, Spieler-Patch + Refresh
|
||||
- [x] **Berechtigungen:** `canReadSchedule` / `canWriteSchedule` in `ClubPermissionHelpers.kt`
|
||||
- [x] **UI:** `ScheduleScreen.kt` – Tab **Terminplan** (`MainTab.Schedule`), Home-Kachel bei Lese-Recht, Liste + Detail, Aufstellung (R/P/S) bei Schreib-Recht
|
||||
- [x] **Noch nicht mobil:** CSV-Import (`POST /api/matches/import`), MyTT-Tabellen-Fetch (`POST .../table/.../fetch`), Galerie/Lineup wie Web – bei Bedarf spätere Phase
|
||||
|
||||
---
|
||||
|
||||
@@ -158,10 +163,10 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug – in Web nach `apiClien
|
||||
|
||||
## Phase 8 – Freigaben & Verwaltung
|
||||
|
||||
- [ ] **Ausstehende Freigaben** (`PendingApprovalsView.vue`)
|
||||
- [ ] **Team-Management** (`TeamManagementView.vue`)
|
||||
- [ ] **Berechtigungen** (`PermissionsView.vue`) – rollenbasiert
|
||||
- [ ] **Logs** (`LogsView.vue`) – eher Admin; nur wenn nötig mobil
|
||||
- [x] **Ausstehende Freigaben** – `ClubApprovalsApi`, `PendingApprovalsManager`, Screen unter „Mehr“ → Club-Verwaltung (`ClubAdminScreens.kt`)
|
||||
- [x] **Team-Management** – Deep-Link `openBackendPath("/team-management")` bei `canReadTeams()` (volle Parität zur Web-`TeamManagementView` bewusst nicht mobil)
|
||||
- [x] **Berechtigungen** – erweiterte `PermissionsApi`, `PermissionsAdminManager`, UI Rolle/Status/Anpassen mit `RolePermissionMatrix` (`ClubAdminScreens.kt`)
|
||||
- [x] **Logs** – `ApiLogsApi`, `ApiLogsManager`, Liste + Pagination + Detail (`ClubAdminScreens.kt`); `AppDependencies` / Logout / 401 räumen Manager auf
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ kotlin {
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.yalantis.ucrop)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import de.tt_tagebuch.shared.api.AccidentApi
|
||||
import de.tt_tagebuch.shared.api.ApiLogsApi
|
||||
import de.tt_tagebuch.shared.api.ClubApprovalsApi
|
||||
import de.tt_tagebuch.shared.api.ApiConfig
|
||||
import de.tt_tagebuch.shared.api.AuthApi
|
||||
import de.tt_tagebuch.shared.api.PublicAuthApi
|
||||
import de.tt_tagebuch.shared.api.ClubTeamsApi
|
||||
import de.tt_tagebuch.shared.api.ClubsApi
|
||||
import de.tt_tagebuch.shared.api.DiaryApi
|
||||
import de.tt_tagebuch.shared.api.DiaryMemberActivitiesApi
|
||||
@@ -14,26 +17,36 @@ import de.tt_tagebuch.shared.api.DiaryMemberApi
|
||||
import de.tt_tagebuch.shared.api.GroupApi
|
||||
import de.tt_tagebuch.shared.api.ParticipantsApi
|
||||
import de.tt_tagebuch.shared.api.PredefinedActivitiesApi
|
||||
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.MembersApi
|
||||
import de.tt_tagebuch.shared.api.OfficialTournamentsApi
|
||||
import de.tt_tagebuch.shared.api.PermissionsApi
|
||||
import de.tt_tagebuch.shared.api.SessionApi
|
||||
import de.tt_tagebuch.shared.api.TrainingGroupsApi
|
||||
import de.tt_tagebuch.shared.api.TrainingStatsApi
|
||||
import de.tt_tagebuch.shared.api.TrainingTimesApi
|
||||
import de.tt_tagebuch.shared.api.TournamentsApi
|
||||
import de.tt_tagebuch.shared.api.http.AndroidHttpClientEngineFactory
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.http.PublicHttpClient
|
||||
import de.tt_tagebuch.shared.state.AndroidClubStorage
|
||||
import de.tt_tagebuch.shared.state.AndroidLanguageStorage
|
||||
import de.tt_tagebuch.shared.state.AndroidTokenStorage
|
||||
import de.tt_tagebuch.shared.state.ApiLogsManager
|
||||
import de.tt_tagebuch.shared.state.AuthManager
|
||||
import de.tt_tagebuch.shared.state.ClubInternalTournamentsManager
|
||||
import de.tt_tagebuch.shared.state.ClubManager
|
||||
import de.tt_tagebuch.shared.state.DiaryManager
|
||||
import de.tt_tagebuch.shared.state.LanguageManager
|
||||
import de.tt_tagebuch.shared.state.MembersManager
|
||||
import de.tt_tagebuch.shared.state.MutableTokenProvider
|
||||
import de.tt_tagebuch.shared.state.OfficialTournamentsReadManager
|
||||
import de.tt_tagebuch.shared.state.PendingApprovalsManager
|
||||
import de.tt_tagebuch.shared.state.PermissionsAdminManager
|
||||
import de.tt_tagebuch.shared.state.MutableTokenProvider
|
||||
import de.tt_tagebuch.shared.state.ScheduleManager
|
||||
import de.tt_tagebuch.shared.state.TrainingStatsManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -73,12 +86,20 @@ class AppDependencies(context: Context) {
|
||||
sessionApi = SessionApi(client),
|
||||
)
|
||||
|
||||
private val permissionsApi = PermissionsApi(client)
|
||||
|
||||
val clubManager = ClubManager(
|
||||
clubStorage = AndroidClubStorage(context.applicationContext),
|
||||
clubsApi = ClubsApi(client),
|
||||
permissionsApi = PermissionsApi(client),
|
||||
permissionsApi = permissionsApi,
|
||||
)
|
||||
|
||||
val pendingApprovalsManager = PendingApprovalsManager(ClubApprovalsApi(client))
|
||||
val permissionsAdminManager = PermissionsAdminManager(permissionsApi)
|
||||
val apiLogsManager = ApiLogsManager(ApiLogsApi(client))
|
||||
val clubInternalTournamentsManager = ClubInternalTournamentsManager(TournamentsApi(client))
|
||||
val officialTournamentsReadManager = OfficialTournamentsReadManager(OfficialTournamentsApi(client))
|
||||
|
||||
val diaryManager = DiaryManager(
|
||||
DiaryApi(client),
|
||||
ParticipantsApi(client),
|
||||
@@ -96,6 +117,10 @@ class AppDependencies(context: Context) {
|
||||
TrainingTimesApi(client),
|
||||
)
|
||||
val trainingStatsManager = TrainingStatsManager(TrainingStatsApi(client))
|
||||
val scheduleManager = ScheduleManager(
|
||||
ClubTeamsApi(client),
|
||||
MatchesApi(client),
|
||||
)
|
||||
val languageManager = LanguageManager(AndroidLanguageStorage(context.applicationContext))
|
||||
val sessionApi = SessionApi(client)
|
||||
|
||||
|
||||
@@ -47,8 +47,10 @@ 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.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.EmojiEvents
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
@@ -81,8 +83,13 @@ import de.tt_tagebuch.app.pdf.writeTrainingPlanPdf
|
||||
import de.tt_tagebuch.shared.api.memberProfileImagePath
|
||||
import de.tt_tagebuch.shared.api.toAbsoluteUrl
|
||||
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
|
||||
import de.tt_tagebuch.shared.api.models.canReadApprovals
|
||||
import de.tt_tagebuch.shared.api.models.canReadClubPermissions
|
||||
import de.tt_tagebuch.shared.api.models.canReadDiary
|
||||
import de.tt_tagebuch.shared.api.models.canReadTeams
|
||||
import de.tt_tagebuch.shared.api.models.canReadMembers
|
||||
import de.tt_tagebuch.shared.api.models.canReadSchedule
|
||||
import de.tt_tagebuch.shared.api.models.canReadTournaments
|
||||
import de.tt_tagebuch.shared.api.models.canWriteDiary
|
||||
import de.tt_tagebuch.shared.api.models.canWriteMembers
|
||||
import de.tt_tagebuch.shared.api.models.mainActivityImagePath
|
||||
@@ -115,6 +122,7 @@ import de.tt_tagebuch.shared.api.models.TrainingGroupDto
|
||||
import de.tt_tagebuch.shared.api.models.TrainingTimeDto
|
||||
import de.tt_tagebuch.shared.api.models.toSetBody
|
||||
import de.tt_tagebuch.shared.api.models.Member
|
||||
import de.tt_tagebuch.shared.api.models.UserClubPermissions
|
||||
import de.tt_tagebuch.shared.i18n.MobileStrings
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
@@ -129,6 +137,14 @@ private const val MAIN_NAV_RAIL_MIN_WIDTH_DP = 600
|
||||
private val ScreenHorizontalPadding = 20.dp
|
||||
private val TouchMinHeight = 48.dp
|
||||
|
||||
private fun visibleMainTabs(perms: UserClubPermissions?): List<MainTab> =
|
||||
MainTab.entries.filter { tab ->
|
||||
when (tab) {
|
||||
MainTab.Tournaments -> perms?.canReadTournaments() == true
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private const val AUTH_ROUTE_LOGIN = "login"
|
||||
private const val AUTH_ROUTE_REGISTER = "register"
|
||||
private const val AUTH_ROUTE_FORGOT = "forgot"
|
||||
@@ -139,7 +155,9 @@ private enum class MainTab {
|
||||
Home,
|
||||
Diary,
|
||||
Members,
|
||||
Schedule,
|
||||
Stats,
|
||||
Tournaments,
|
||||
Settings,
|
||||
}
|
||||
|
||||
@@ -162,6 +180,12 @@ fun AppRoot(dependencies: AppDependencies) {
|
||||
dependencies.diaryManager.clear()
|
||||
dependencies.membersManager.clear()
|
||||
dependencies.trainingStatsManager.clear()
|
||||
dependencies.scheduleManager.clear()
|
||||
dependencies.pendingApprovalsManager.clear()
|
||||
dependencies.permissionsAdminManager.clear()
|
||||
dependencies.apiLogsManager.clear()
|
||||
dependencies.clubInternalTournamentsManager.clear()
|
||||
dependencies.officialTournamentsReadManager.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +208,14 @@ private fun MainTabs(dependencies: AppDependencies) {
|
||||
var diarySelectedEntryId by remember { mutableStateOf<Int?>(null) }
|
||||
var membersNestedOpen by remember { mutableStateOf(false) }
|
||||
val useWideMainNav = LocalConfiguration.current.screenWidthDp >= MAIN_NAV_RAIL_MIN_WIDTH_DP
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
val visibleTabs = visibleMainTabs(clubState.currentPermissions)
|
||||
|
||||
LaunchedEffect(visibleTabs, selectedTab) {
|
||||
if (!visibleTabs.contains(selectedTab)) {
|
||||
selectedTab = MainTab.Home
|
||||
}
|
||||
}
|
||||
|
||||
val isNestedDetail = when (selectedTab) {
|
||||
MainTab.Diary -> diarySelectedEntryId != null
|
||||
@@ -205,6 +237,7 @@ private fun MainTabs(dependencies: AppDependencies) {
|
||||
MainNavigationRail(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { selectMainTab(it) },
|
||||
visibleTabs = visibleTabs,
|
||||
)
|
||||
Divider(
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
@@ -240,7 +273,7 @@ private fun MainTabs(dependencies: AppDependencies) {
|
||||
contentColor = MaterialTheme.colors.onSurface,
|
||||
elevation = 8.dp,
|
||||
) {
|
||||
MainTab.values().forEach { tab ->
|
||||
visibleTabs.forEach { tab ->
|
||||
BottomNavigationItem(
|
||||
icon = { Icon(mainTabIcon(tab), contentDescription = tabTitle(tab)) },
|
||||
label = { Text(tabTitle(tab)) },
|
||||
@@ -280,6 +313,8 @@ private fun MainTabContent(
|
||||
dependencies = dependencies,
|
||||
onNestedOpenChange = onMembersNestedOpenChange,
|
||||
)
|
||||
MainTab.Schedule -> ScheduleScreen(dependencies)
|
||||
MainTab.Tournaments -> TournamentsScreen(dependencies)
|
||||
MainTab.Stats -> TrainingStatsScreen(dependencies)
|
||||
MainTab.Settings -> SettingsScreen(dependencies)
|
||||
}
|
||||
@@ -337,6 +372,15 @@ private fun HomeScreen(
|
||||
subtitle = tr("home.tileMembers", "Liste und Profile"),
|
||||
onClick = { onOpenTab(MainTab.Members) },
|
||||
)
|
||||
clubState.currentPermissions?.let { p ->
|
||||
if (p.canReadSchedule()) {
|
||||
HomeHubTile(
|
||||
title = tr("navigation.schedule", "Terminplan"),
|
||||
subtitle = tr("home.tileSchedule", "Mannschaften, Spiele, Tabelle"),
|
||||
onClick = { onOpenTab(MainTab.Schedule) },
|
||||
)
|
||||
}
|
||||
}
|
||||
HomeHubTile(
|
||||
title = tr("navigation.statistics", "Statistik"),
|
||||
subtitle = tr("home.tileStats", "Kennzahlen und Teilnahmen"),
|
||||
@@ -397,6 +441,7 @@ private fun HomeHubTile(title: String, subtitle: String, onClick: () -> Unit) {
|
||||
private fun MainNavigationRail(
|
||||
selectedTab: MainTab,
|
||||
onTabSelected: (MainTab) -> Unit,
|
||||
visibleTabs: List<MainTab>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
@@ -407,7 +452,7 @@ private fun MainNavigationRail(
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
MainTab.values().forEach { tab ->
|
||||
visibleTabs.forEach { tab ->
|
||||
val selected = selectedTab == tab
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -455,6 +500,8 @@ private fun mainTabIcon(tab: MainTab): ImageVector = when (tab) {
|
||||
MainTab.Home -> Icons.Filled.Home
|
||||
MainTab.Diary -> Icons.Filled.DateRange
|
||||
MainTab.Members -> Icons.Filled.People
|
||||
MainTab.Schedule -> Icons.AutoMirrored.Filled.List
|
||||
MainTab.Tournaments -> Icons.Filled.EmojiEvents
|
||||
MainTab.Stats -> Icons.Filled.BarChart
|
||||
MainTab.Settings -> Icons.Filled.Settings
|
||||
}
|
||||
@@ -3913,6 +3960,15 @@ private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> U
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(dependencies: AppDependencies) {
|
||||
var clubAdminSection by remember { mutableStateOf<ClubAdminSettingsSection?>(null) }
|
||||
if (clubAdminSection != null) {
|
||||
ClubAdminFlowScreen(
|
||||
destination = clubAdminSection!!,
|
||||
dependencies = dependencies,
|
||||
onBack = { clubAdminSection = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
val authState by dependencies.authManager.state.collectAsState()
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
var sessionStatus by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
@@ -3936,6 +3992,33 @@ private fun SettingsScreen(dependencies: AppDependencies) {
|
||||
clubState.currentPermissions?.let { permissions ->
|
||||
DetailLine(tr("mobile.role", "Rolle"), permissions.role)
|
||||
}
|
||||
val clubId = clubState.currentClubId
|
||||
val perms = clubState.currentPermissions
|
||||
if (clubId != null && perms != null) {
|
||||
SectionTitle(tr("mobile.clubAdmin", "Club-Verwaltung"))
|
||||
if (perms.canReadApprovals()) {
|
||||
TextButton(
|
||||
onClick = { clubAdminSection = ClubAdminSettingsSection.Pending },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(tr("mobile.pendingApprovals", "Ausstehende Freigaben")) }
|
||||
}
|
||||
if (perms.canReadClubPermissions()) {
|
||||
TextButton(
|
||||
onClick = { clubAdminSection = ClubAdminSettingsSection.Permissions },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(tr("mobile.permissionsAdmin", "Berechtigungen")) }
|
||||
}
|
||||
TextButton(
|
||||
onClick = { clubAdminSection = ClubAdminSettingsSection.Logs },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(tr("mobile.apiLogs", "API-Logs")) }
|
||||
if (perms.canReadTeams()) {
|
||||
TextButton(
|
||||
onClick = { dependencies.openBackendPath("/team-management") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(tr("mobile.teamManagementWeb", "Team-Verwaltung (Web)")) }
|
||||
}
|
||||
}
|
||||
SectionTitle(tr("mobile.language", "Sprache"))
|
||||
MobileStrings.supportedLanguages.forEach { language ->
|
||||
TextButton(
|
||||
@@ -3992,6 +4075,10 @@ private fun SettingsScreen(dependencies: AppDependencies) {
|
||||
dependencies.diaryManager.clear()
|
||||
dependencies.membersManager.clear()
|
||||
dependencies.trainingStatsManager.clear()
|
||||
dependencies.scheduleManager.clear()
|
||||
dependencies.pendingApprovalsManager.clear()
|
||||
dependencies.permissionsAdminManager.clear()
|
||||
dependencies.apiLogsManager.clear()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
@@ -4411,6 +4498,8 @@ private fun tabTitle(tab: MainTab): String = when (tab) {
|
||||
MainTab.Home -> tr("navigation.home", "Start")
|
||||
MainTab.Diary -> tr("navigation.diary", "Tagebuch")
|
||||
MainTab.Members -> tr("navigation.members", "Mitglieder")
|
||||
MainTab.Schedule -> tr("navigation.schedule", "Terminplan")
|
||||
MainTab.Tournaments -> tr("navigation.clubTournaments", "Turniere")
|
||||
MainTab.Stats -> tr("navigation.statistics", "Statistik")
|
||||
MainTab.Settings -> tr("mobile.more", "Mehr")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.Divider
|
||||
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.mutableStateMapOf
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.tt_tagebuch.app.AppDependencies
|
||||
import de.tt_tagebuch.shared.api.RolePermissionMatrix
|
||||
import de.tt_tagebuch.shared.api.models.ApiLogDetailDto
|
||||
import de.tt_tagebuch.shared.api.models.ClubPermissionMemberDto
|
||||
import de.tt_tagebuch.shared.api.models.PermissionResourceDto
|
||||
import de.tt_tagebuch.shared.api.models.canReadApprovals
|
||||
import de.tt_tagebuch.shared.api.models.canReadClubPermissions
|
||||
import de.tt_tagebuch.shared.api.models.canWriteApprovals
|
||||
import de.tt_tagebuch.shared.api.models.canWriteClubPermissions
|
||||
import de.tt_tagebuch.shared.i18n.MobileStrings
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
private val ClubAdminPad = 20.dp
|
||||
private val ClubAdminTouchMin = 48.dp
|
||||
|
||||
internal enum class ClubAdminSettingsSection {
|
||||
Pending,
|
||||
Permissions,
|
||||
Logs,
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClubAdminFlowScreen(
|
||||
destination: ClubAdminSettingsSection,
|
||||
dependencies: AppDependencies,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
when (destination) {
|
||||
ClubAdminSettingsSection.Pending ->
|
||||
ClubAdminPendingScreen(dependencies = dependencies, onBack = onBack)
|
||||
ClubAdminSettingsSection.Permissions ->
|
||||
ClubAdminPermissionsScreen(dependencies = dependencies, onBack = onBack)
|
||||
ClubAdminSettingsSection.Logs ->
|
||||
ClubAdminLogsScreen(dependencies = dependencies, onBack = onBack)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClubAdminTopBar(title: String, onBack: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.h6, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
private fun readTri(root: JsonObject?, resource: String, action: String): Boolean? {
|
||||
val mod = root?.get(resource) ?: return null
|
||||
val obj = mod as? JsonObject ?: return null
|
||||
if (!obj.containsKey(action)) return null
|
||||
val el = obj[action] ?: return null
|
||||
if (el is JsonNull) return null
|
||||
return (el as? JsonPrimitive)?.booleanOrNull
|
||||
}
|
||||
|
||||
private fun overridesToJson(
|
||||
structure: Map<String, PermissionResourceDto>,
|
||||
overrides: Map<String, Boolean?>,
|
||||
): JsonObject = buildJsonObject {
|
||||
structure.forEach { (res, spec) ->
|
||||
val inner = buildJsonObject {
|
||||
spec.actions.forEach { a ->
|
||||
val k = permKey(res, a)
|
||||
when (val v = overrides[k]) {
|
||||
true -> put(a, JsonPrimitive(true))
|
||||
false -> put(a, JsonPrimitive(false))
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inner.isNotEmpty()) put(res, inner)
|
||||
}
|
||||
}
|
||||
|
||||
private fun permKey(resource: String, action: String) = "$resource#$action"
|
||||
|
||||
@Composable
|
||||
private fun ClubAdminPendingScreen(dependencies: AppDependencies, onBack: () -> Unit) {
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
val pendingState by dependencies.pendingApprovalsManager.state.collectAsState()
|
||||
val clubId = clubState.currentClubId ?: return
|
||||
val perms = clubState.currentPermissions
|
||||
val canWrite = perms?.canWriteApprovals() == true
|
||||
val scope = rememberCoroutineScope()
|
||||
var actionError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(clubId) {
|
||||
dependencies.pendingApprovalsManager.load(clubId)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = ClubAdminPad, vertical = 16.dp),
|
||||
) {
|
||||
ClubAdminTopBar(tr("mobile.pendingApprovals", "Ausstehende Freigaben"), onBack)
|
||||
actionError?.let {
|
||||
Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp))
|
||||
}
|
||||
if (perms != null && !perms.canReadApprovals()) {
|
||||
Text(tr("mobile.noAccess", "Keine Berechtigung."), modifier = Modifier.padding(top = 8.dp))
|
||||
return
|
||||
}
|
||||
if (pendingState.isLoading && pendingState.pending.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
|
||||
return
|
||||
}
|
||||
pendingState.error?.let { Text(it, color = MaterialTheme.colors.error) }
|
||||
if (pendingState.pending.isEmpty()) {
|
||||
Text(tr("mobile.pendingEmpty", "Keine ausstehenden Anträge."), modifier = Modifier.padding(top = 16.dp))
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(pendingState.pending, key = { it.userId }) { row ->
|
||||
val u = row.user
|
||||
val label = listOfNotNull(u?.firstName, u?.lastName).joinToString(" ").trim()
|
||||
.ifEmpty { u?.email ?: "User ${row.userId}" }
|
||||
Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(label, fontWeight = FontWeight.SemiBold)
|
||||
u?.email?.let { Text(it, style = MaterialTheme.typography.caption) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
actionError = null
|
||||
scope.launch {
|
||||
try {
|
||||
dependencies.pendingApprovalsManager.approve(clubId, row.userId)
|
||||
} catch (t: Throwable) {
|
||||
actionError = t.message
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = canWrite,
|
||||
modifier = Modifier.weight(1f).heightIn(min = ClubAdminTouchMin),
|
||||
) { Text(tr("mobile.approve", "Freigeben")) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
actionError = null
|
||||
scope.launch {
|
||||
try {
|
||||
dependencies.pendingApprovalsManager.reject(clubId, row.userId)
|
||||
} catch (t: Throwable) {
|
||||
actionError = t.message
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = canWrite,
|
||||
modifier = Modifier.weight(1f).heightIn(min = ClubAdminTouchMin),
|
||||
) { Text(tr("mobile.reject", "Ablehnen")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClubAdminPermissionsScreen(dependencies: AppDependencies, onBack: () -> Unit) {
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
val adminState by dependencies.permissionsAdminManager.state.collectAsState()
|
||||
val clubId = clubState.currentClubId ?: return
|
||||
val perms = clubState.currentPermissions
|
||||
val canWrite = perms?.canWriteClubPermissions() == true
|
||||
val scope = rememberCoroutineScope()
|
||||
var roleMenuFor by remember { mutableStateOf<Int?>(null) }
|
||||
var customizeFor by remember { mutableStateOf<ClubPermissionMemberDto?>(null) }
|
||||
val overrides = remember { mutableStateMapOf<String, Boolean?>() }
|
||||
var customizeSaving by remember { mutableStateOf(false) }
|
||||
var opError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(clubId) {
|
||||
dependencies.permissionsAdminManager.load(clubId)
|
||||
}
|
||||
|
||||
LaunchedEffect(customizeFor, adminState.permissionStructure) {
|
||||
val m = customizeFor ?: return@LaunchedEffect
|
||||
if (adminState.permissionStructure.isEmpty()) return@LaunchedEffect
|
||||
overrides.clear()
|
||||
adminState.permissionStructure.forEach { (res, spec) ->
|
||||
spec.actions.forEach { a ->
|
||||
overrides[permKey(res, a)] = readTri(m.permissions, res, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = ClubAdminPad, vertical = 16.dp),
|
||||
) {
|
||||
ClubAdminTopBar(tr("mobile.permissionsAdmin", "Berechtigungen"), onBack)
|
||||
opError?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp)) }
|
||||
if (perms != null && !perms.canReadClubPermissions()) {
|
||||
Text(tr("mobile.noAccess", "Keine Berechtigung."))
|
||||
return
|
||||
}
|
||||
if (adminState.isLoading && adminState.members.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
|
||||
return
|
||||
}
|
||||
adminState.error?.let { Text(it, color = MaterialTheme.colors.error) }
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(adminState.members, key = { it.userId }) { m ->
|
||||
val email = m.user?.email ?: "User ${m.userId}"
|
||||
Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(email, fontWeight = FontWeight.SemiBold)
|
||||
if (m.isOwner) {
|
||||
Text(tr("mobile.clubOwner", "Vereinsbesitzer"), style = MaterialTheme.typography.caption)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(tr("mobile.role", "Rolle"))
|
||||
Box {
|
||||
TextButton(
|
||||
enabled = canWrite && !m.isOwner,
|
||||
onClick = { roleMenuFor = m.userId },
|
||||
) {
|
||||
Text(adminState.availableRoles.firstOrNull { it.value == m.role }?.label ?: m.role)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = roleMenuFor == m.userId,
|
||||
onDismissRequest = { roleMenuFor = null },
|
||||
) {
|
||||
adminState.availableRoles.forEach { role ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
roleMenuFor = null
|
||||
opError = null
|
||||
scope.launch {
|
||||
try {
|
||||
dependencies.permissionsAdminManager.updateRole(clubId, m.userId, role.value)
|
||||
} catch (t: Throwable) {
|
||||
opError = t.message
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text(role.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(tr("mobile.memberApproved", "Aktiv"))
|
||||
Switch(
|
||||
checked = m.approved != false,
|
||||
enabled = canWrite && !m.isOwner,
|
||||
onCheckedChange = { on ->
|
||||
opError = null
|
||||
scope.launch {
|
||||
try {
|
||||
dependencies.permissionsAdminManager.updateApproved(clubId, m.userId, on)
|
||||
} catch (t: Throwable) {
|
||||
opError = t.message
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
enabled = canWrite && !m.isOwner,
|
||||
onClick = { customizeFor = m },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = ClubAdminTouchMin),
|
||||
) { Text(tr("mobile.customizePermissions", "Berechtigungen anpassen")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dialogMember = customizeFor
|
||||
if (dialogMember != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!customizeSaving) customizeFor = null },
|
||||
title = { Text(dialogMember.user?.email ?: "User ${dialogMember.userId}") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.heightIn(max = 420.dp),
|
||||
) {
|
||||
adminState.permissionStructure.forEach { (res, spec) ->
|
||||
Text(
|
||||
spec.label.ifEmpty { res },
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
spec.actions.forEach { action ->
|
||||
val k = permKey(res, action)
|
||||
val tri = overrides[k]
|
||||
val inherited = RolePermissionMatrix.defaultAction(dialogMember.role, res, action)
|
||||
val label = when (tri) {
|
||||
null -> tr("mobile.permInherit", "Standard") + " (${if (inherited) tr("mobile.yes", "Ja") else tr("mobile.no", "Nein")})"
|
||||
true -> tr("mobile.permAllow", "Erlaubt")
|
||||
false -> tr("mobile.permDeny", "Verweigert")
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
overrides[k] = when (tri) {
|
||||
null -> true
|
||||
true -> false
|
||||
false -> null
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(action, style = MaterialTheme.typography.caption)
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !customizeSaving,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
customizeSaving = true
|
||||
opError = null
|
||||
try {
|
||||
val json = overridesToJson(adminState.permissionStructure, overrides)
|
||||
dependencies.permissionsAdminManager.saveCustomPermissions(
|
||||
clubId,
|
||||
dialogMember.userId,
|
||||
json,
|
||||
)
|
||||
customizeFor = null
|
||||
} catch (t: Throwable) {
|
||||
opError = t.message
|
||||
} finally {
|
||||
customizeSaving = false
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text(tr("common.save", "Speichern")) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(enabled = !customizeSaving, onClick = { customizeFor = null }) {
|
||||
Text(tr("common.cancel", "Abbrechen"))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClubAdminLogsScreen(dependencies: AppDependencies, onBack: () -> Unit) {
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
|
||||
val logsState by dependencies.apiLogsManager.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var pathFilter by remember { mutableStateOf("") }
|
||||
var detailId by remember { mutableStateOf<Int?>(null) }
|
||||
var detail by remember { mutableStateOf<ApiLogDetailDto?>(null) }
|
||||
var detailLoading by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
dependencies.apiLogsManager.load(resetOffset = true)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = ClubAdminPad, vertical = 16.dp),
|
||||
) {
|
||||
ClubAdminTopBar(tr("mobile.apiLogs", "API-Logs"), onBack)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = pathFilter,
|
||||
onValueChange = { pathFilter = it },
|
||||
label = { Text(tr("mobile.logPathFilter", "Pfad enthält")) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
dependencies.apiLogsManager.load(
|
||||
pathContains = pathFilter.takeIf { it.isNotBlank() },
|
||||
resetOffset = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.heightIn(min = ClubAdminTouchMin),
|
||||
) { Text(tr("mobile.apply", "Anwenden")) }
|
||||
}
|
||||
logsState.error?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
enabled = !logsState.isLoading && logsState.offset > 0,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
dependencies.apiLogsManager.previousPage(pathContains = pathFilter.takeIf { it.isNotBlank() })
|
||||
}
|
||||
},
|
||||
modifier = Modifier.heightIn(min = ClubAdminTouchMin),
|
||||
) { Text(tr("mobile.prevPage", "Zurück")) }
|
||||
Text(
|
||||
"${logsState.offset + 1}–${(logsState.offset + logsState.logs.size).coerceAtLeast(logsState.offset)} / ${logsState.total}",
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
OutlinedButton(
|
||||
enabled = !logsState.isLoading && logsState.offset + logsState.logs.size < logsState.total,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
dependencies.apiLogsManager.nextPage(pathContains = pathFilter.takeIf { it.isNotBlank() })
|
||||
}
|
||||
},
|
||||
modifier = Modifier.heightIn(min = ClubAdminTouchMin),
|
||||
) { Text(tr("mobile.nextPage", "Weiter")) }
|
||||
}
|
||||
if (logsState.isLoading && logsState.logs.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
items(logsState.logs, key = { it.id }) { row ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
elevation = 1.dp,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
detailId = row.id
|
||||
detail = null
|
||||
detailLoading = true
|
||||
detail = dependencies.apiLogsManager.fetchDetail(row.id)
|
||||
detailLoading = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(4.dp)) {
|
||||
Text(
|
||||
"${row.method ?: "?"} ${row.statusCode ?: "-"}",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(row.path ?: "-", style = MaterialTheme.typography.caption)
|
||||
row.createdAt?.let { Text(it, style = MaterialTheme.typography.caption) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (detailId != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { detailId = null },
|
||||
title = { Text(tr("mobile.logDetail", "Log-Detail")) },
|
||||
text = {
|
||||
when {
|
||||
detailLoading -> CircularProgressIndicator()
|
||||
detail != null -> {
|
||||
val d = detail!!
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("id=${d.id}")
|
||||
d.method?.let { Text("method=$it") }
|
||||
d.path?.let { Text("path=$it") }
|
||||
d.statusCode?.let { Text("status=$it") }
|
||||
d.executionTime?.let { Text("ms=$it") }
|
||||
d.createdAt?.let { Text(it) }
|
||||
d.errorMessage?.takeIf { it.isNotBlank() }?.let { Text(it, color = MaterialTheme.colors.error) }
|
||||
d.ipAddress?.let { Text("ip=$it") }
|
||||
d.userAgent?.let { Text(it, style = MaterialTheme.typography.caption) }
|
||||
}
|
||||
}
|
||||
else -> Text(tr("mobile.logDetailFailed", "Konnte nicht geladen werden."))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { detailId = null }) { Text(tr("common.close", "Schließen")) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
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.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.tt_tagebuch.app.AppDependencies
|
||||
import de.tt_tagebuch.app.stats.TrainingStatsDerived
|
||||
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
|
||||
import de.tt_tagebuch.shared.api.models.ScheduleMatchScope
|
||||
import de.tt_tagebuch.shared.api.models.ScheduleViewMode
|
||||
import de.tt_tagebuch.shared.api.models.canReadSchedule
|
||||
import de.tt_tagebuch.shared.api.models.canWriteSchedule
|
||||
import de.tt_tagebuch.shared.i18n.MobileStrings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val SchedulePad = 20.dp
|
||||
private val ScheduleTouchMin = 48.dp
|
||||
|
||||
@Composable
|
||||
internal fun ScheduleScreen(dependencies: AppDependencies) {
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
val scheduleState by dependencies.scheduleManager.state.collectAsState()
|
||||
val membersState by dependencies.membersManager.state.collectAsState()
|
||||
val clubId = clubState.currentClubId ?: return
|
||||
val permissions = clubState.currentPermissions
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
var teamMenu by remember { mutableStateOf(false) }
|
||||
var otherTeamMenu by remember { mutableStateOf(false) }
|
||||
var detailMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
|
||||
var playerMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
|
||||
var playerError by remember { mutableStateOf<String?>(null) }
|
||||
var playerSaving by remember { mutableStateOf(false) }
|
||||
|
||||
var readyIds by remember { mutableStateOf(emptyList<Int>()) }
|
||||
var plannedIds by remember { mutableStateOf(emptyList<Int>()) }
|
||||
var playedIds by remember { mutableStateOf(emptyList<Int>()) }
|
||||
|
||||
LaunchedEffect(playerMatch?.id) {
|
||||
val pm = playerMatch ?: return@LaunchedEffect
|
||||
readyIds = pm.playersReady
|
||||
plannedIds = pm.playersPlanned
|
||||
playedIds = pm.playersPlayed
|
||||
}
|
||||
|
||||
LaunchedEffect(clubId) {
|
||||
dependencies.scheduleManager.clear()
|
||||
dependencies.scheduleManager.loadClubTeams(clubId)
|
||||
}
|
||||
|
||||
if (permissions != null && !permissions.canReadSchedule()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(SchedulePad)
|
||||
.imePadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
Text(tr("schedule.noPermission", "Keine Berechtigung für den Spielplan."), style = MaterialTheme.typography.body1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = SchedulePad, vertical = 12.dp),
|
||||
) {
|
||||
Text(tr("navigation.schedule", "Terminplan"), style = MaterialTheme.typography.h5, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
dependencies.scheduleManager.loadOverallSchedule(clubId)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).heightIn(min = ScheduleTouchMin),
|
||||
) {
|
||||
Text(tr("schedule.overallSchedule", "Gesamtplan"), maxLines = 2)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
dependencies.scheduleManager.loadAdultSchedule(clubId)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).heightIn(min = ScheduleTouchMin),
|
||||
) {
|
||||
Text(tr("schedule.adultSchedule", "Erwachsene"), maxLines = 2)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box {
|
||||
OutlinedButton(
|
||||
onClick = { teamMenu = true },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
|
||||
) {
|
||||
val label = scheduleState.selectedTeam?.let { t ->
|
||||
val lg = t.league?.name?.takeIf { it.isNotBlank() }
|
||||
if (lg != null) "${t.name} ($lg)" else t.name
|
||||
} ?: tr("schedule.selectTeam", "Mannschaft wählen")
|
||||
Text(label, maxLines = 2)
|
||||
}
|
||||
DropdownMenu(expanded = teamMenu, onDismissRequest = { teamMenu = false }) {
|
||||
scheduleState.teams.forEach { team ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
teamMenu = false
|
||||
scope.launch { dependencies.scheduleManager.selectTeam(clubId, team.id) }
|
||||
},
|
||||
) {
|
||||
val lg = team.league?.name?.takeIf { it.isNotBlank() }
|
||||
Text(if (lg != null) "${team.name} ($lg)" else team.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduleState.viewMode == ScheduleViewMode.Team && scheduleState.selectedTeam != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(tr("schedule.matchScope", "Spiele anzeigen"), style = MaterialTheme.typography.caption)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
ScheduleScopeChip(
|
||||
label = tr("schedule.ownTeamMatches", "Eigene"),
|
||||
selected = scheduleState.matchScope == ScheduleMatchScope.Own,
|
||||
onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.Own) },
|
||||
)
|
||||
ScheduleScopeChip(
|
||||
label = tr("schedule.allLeagueMatches", "Alle"),
|
||||
selected = scheduleState.matchScope == ScheduleMatchScope.All,
|
||||
onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.All) },
|
||||
)
|
||||
ScheduleScopeChip(
|
||||
label = tr("schedule.otherTeamMatches", "Andere"),
|
||||
selected = scheduleState.matchScope == ScheduleMatchScope.Other,
|
||||
onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.Other) },
|
||||
)
|
||||
}
|
||||
if (scheduleState.matchScope == ScheduleMatchScope.Other) {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { otherTeamMenu = true },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
|
||||
) {
|
||||
Text(
|
||||
scheduleState.otherTeamName.ifBlank { tr("schedule.selectOtherTeam", "Mannschaft wählen") },
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = otherTeamMenu, onDismissRequest = { otherTeamMenu = false }) {
|
||||
scheduleState.leagueTeamOptions.forEach { name ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
otherTeamMenu = false
|
||||
dependencies.scheduleManager.setOtherTeamName(name)
|
||||
},
|
||||
) { Text(name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { scope.launch { dependencies.scheduleManager.refresh(clubId) } },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
|
||||
) { Text(tr("mobile.refresh", "Aktualisieren")) }
|
||||
|
||||
if (scheduleState.isLoading) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
scheduleState.error?.let {
|
||||
Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
|
||||
if (scheduleState.viewMode == ScheduleViewMode.Team && scheduleState.leagueTable.isNotEmpty()) {
|
||||
Text(tr("schedule.leagueTable", "Tabelle"), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp))
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), elevation = 1.dp) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("#", fontWeight = FontWeight.Bold, modifier = Modifier.widthIn(28.dp))
|
||||
Text(tr("schedule.team", "Team"), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
Text(tr("schedule.points", "Pkt"), fontWeight = FontWeight.Bold, modifier = Modifier.widthIn(40.dp))
|
||||
}
|
||||
Divider()
|
||||
scheduleState.leagueTable.forEachIndexed { idx, row ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("${idx + 1}", modifier = Modifier.widthIn(28.dp))
|
||||
Text(row.teamName, modifier = Modifier.weight(1f), maxLines = 2)
|
||||
Text(row.tablePoints, modifier = Modifier.widthIn(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(tr("schedule.games", "Spiele"), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp))
|
||||
|
||||
val matches = scheduleState.displayedMatches
|
||||
if (matches.isEmpty() && !scheduleState.isLoading) {
|
||||
Text(tr("schedule.noGames", "Keine Spiele"), modifier = Modifier.padding(top = 8.dp))
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
items(matches, key = { it.id }) { m ->
|
||||
ScheduleMatchCard(
|
||||
match = m,
|
||||
highlightClubName = clubState.clubs.find { it.id == clubId }?.name.orEmpty(),
|
||||
showLeagueColumn = scheduleState.viewMode != ScheduleViewMode.Team,
|
||||
onClick = { detailMatch = m },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detailMatch?.let { m ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { detailMatch = null },
|
||||
title = { Text("${m.homeTeam?.name ?: "?"} : ${m.guestTeam?.name ?: "?"}") },
|
||||
text = {
|
||||
val scroll = rememberScrollState()
|
||||
Column(Modifier.verticalScroll(scroll)) {
|
||||
Text("${TrainingStatsDerived.formatDateGerman(m.date)} · ${TrainingStatsDerived.weekdayGerman(m.date ?: "")}")
|
||||
Text(
|
||||
tr("schedule.time", "Zeit") + ": " + (m.time?.take(5)?.takeIf { it.isNotBlank() } ?: "—"),
|
||||
)
|
||||
if (m.isCompleted) {
|
||||
Text(
|
||||
tr("schedule.result", "Ergebnis") + ": ${m.homeMatchPoints}:${m.guestMatchPoints}",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
val loc = m.location
|
||||
loc?.name?.takeIf { it.isNotBlank() && it != "Unbekannt" }?.let { hallName ->
|
||||
Text(tr("schedule.location", "Halle") + ": $hallName")
|
||||
val addr = listOfNotNull(
|
||||
loc.address.takeIf { a -> a.isNotBlank() },
|
||||
listOfNotNull(loc.zip.takeIf { z -> z.isNotBlank() }, loc.city.takeIf { c -> c.isNotBlank() })
|
||||
.joinToString(" ")
|
||||
.takeIf { s -> s.isNotBlank() },
|
||||
).joinToString(", ")
|
||||
if (addr.isNotBlank()) Text(addr, style = MaterialTheme.typography.caption)
|
||||
}
|
||||
m.leagueDetails?.name?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(tr("schedule.ageClass", "Liga") + ": $it", style = MaterialTheme.typography.caption)
|
||||
}
|
||||
m.code?.takeIf { it.isNotBlank() }?.let { code ->
|
||||
TextButton(onClick = { clipboard.setText(AnnotatedString(code)) }) {
|
||||
Text(tr("schedule.code", "Code") + ": $code")
|
||||
}
|
||||
}
|
||||
Row {
|
||||
m.homePin?.takeIf { it.isNotBlank() }?.let { pin ->
|
||||
TextButton(onClick = { clipboard.setText(AnnotatedString(pin)) }) {
|
||||
Text(tr("schedule.homePin", "Heim-PIN") + ": $pin")
|
||||
}
|
||||
}
|
||||
m.guestPin?.takeIf { it.isNotBlank() }?.let { pin ->
|
||||
TextButton(onClick = { clipboard.setText(AnnotatedString(pin)) }) {
|
||||
Text(tr("schedule.guestPin", "Gast-PIN") + ": $pin")
|
||||
}
|
||||
}
|
||||
}
|
||||
m.pdfUrl?.takeIf { it.startsWith("http") }?.let { url ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { Text(tr("schedule.openMatchReport", "Bericht öffnen")) }
|
||||
}
|
||||
if (permissions?.canWriteSchedule() == true) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
playerError = null
|
||||
playerMatch = m
|
||||
detailMatch = null
|
||||
},
|
||||
) {
|
||||
Text(tr("schedule.players", "Aufstellung / Spieler"))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { detailMatch = null }) { Text(tr("common.close", "Schließen")) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
playerMatch?.let { m ->
|
||||
LaunchedEffect(m.id, clubId) {
|
||||
dependencies.membersManager.loadMembers(clubId)
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!playerSaving) playerMatch = null },
|
||||
title = { Text(tr("schedule.playerSelectionTitle", "Spieler")) },
|
||||
text = {
|
||||
Column(modifier = Modifier.heightIn(max = 400.dp)) {
|
||||
playerError?.let { Text(it, color = MaterialTheme.colors.error) }
|
||||
val memberList = membersState.members.filter { it.active }
|
||||
if (membersState.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
|
||||
} else {
|
||||
val scroll = rememberScrollState()
|
||||
Column(Modifier.verticalScroll(scroll)) {
|
||||
memberList.forEach { mem ->
|
||||
val id = mem.id
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("${mem.firstName} ${mem.lastName}".trim(), maxLines = 1)
|
||||
}
|
||||
Text("R", style = MaterialTheme.typography.caption)
|
||||
Checkbox(
|
||||
checked = id in readyIds,
|
||||
onCheckedChange = { c ->
|
||||
readyIds = if (c) (readyIds + id).distinct() else readyIds.filter { it != id }
|
||||
},
|
||||
)
|
||||
Text("P", style = MaterialTheme.typography.caption)
|
||||
Checkbox(
|
||||
checked = id in plannedIds,
|
||||
onCheckedChange = { c ->
|
||||
plannedIds = if (c) (plannedIds + id).distinct() else plannedIds.filter { it != id }
|
||||
},
|
||||
)
|
||||
Text("S", style = MaterialTheme.typography.caption)
|
||||
Checkbox(
|
||||
checked = id in playedIds,
|
||||
onCheckedChange = { c ->
|
||||
playedIds = if (c) (playedIds + id).distinct() else playedIds.filter { it != id }
|
||||
},
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !playerSaving,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
playerSaving = true
|
||||
playerError = null
|
||||
runCatching {
|
||||
dependencies.scheduleManager.updateMatchPlayers(
|
||||
clubId = clubId,
|
||||
matchId = m.id,
|
||||
ready = readyIds,
|
||||
planned = plannedIds,
|
||||
played = playedIds,
|
||||
)
|
||||
playerMatch = null
|
||||
}.onFailure { playerError = it.message ?: tr("schedule.errorSavingPlayerSelection", "Speichern fehlgeschlagen") }
|
||||
playerSaving = false
|
||||
}
|
||||
},
|
||||
) { Text(tr("common.save", "Speichern")) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(enabled = !playerSaving, onClick = { playerMatch = null }) {
|
||||
Text(tr("common.cancel", "Abbrechen"))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduleScopeChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = 40.dp),
|
||||
) {
|
||||
Text(label, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduleMatchCard(
|
||||
match: ScheduleMatchDto,
|
||||
highlightClubName: String,
|
||||
showLeagueColumn: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val homeH = highlightClubName.isNotBlank() && match.homeTeam?.name?.contains(highlightClubName) == true
|
||||
val guestH = highlightClubName.isNotBlank() && match.guestTeam?.name?.contains(highlightClubName) == true
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
elevation = 1.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
TrainingStatsDerived.formatDateGerman(match.date),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(match.time?.take(5) ?: "—", style = MaterialTheme.typography.caption)
|
||||
}
|
||||
Text(
|
||||
"${match.homeTeam?.name ?: "?"} : ${match.guestTeam?.name ?: "?"}",
|
||||
color = when {
|
||||
homeH || guestH -> MaterialTheme.colors.primary
|
||||
else -> MaterialTheme.colors.onSurface
|
||||
},
|
||||
)
|
||||
if (showLeagueColumn) {
|
||||
Text(match.leagueDetails?.name ?: "", style = MaterialTheme.typography.caption)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
if (match.isCompleted) {
|
||||
Text("${match.homeMatchPoints}:${match.guestMatchPoints}", fontWeight = FontWeight.Bold)
|
||||
} else {
|
||||
Text("—", color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.tt_tagebuch.app.AppDependencies
|
||||
import de.tt_tagebuch.shared.api.models.OfficialParticipationEntryDto
|
||||
import de.tt_tagebuch.shared.api.models.canReadTournaments
|
||||
import de.tt_tagebuch.shared.i18n.MobileStrings
|
||||
import de.tt_tagebuch.shared.state.ClubTournamentDisplayFilter
|
||||
|
||||
private data class ParticipationFlatRow(
|
||||
val tournamentId: String?,
|
||||
val tournamentTitle: String?,
|
||||
val entry: OfficialParticipationEntryDto,
|
||||
)
|
||||
|
||||
private val TournamentsPad = 20.dp
|
||||
private val TournamentsTouchMin = 48.dp
|
||||
|
||||
@Composable
|
||||
internal fun TournamentsScreen(dependencies: AppDependencies) {
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
val clubId = clubState.currentClubId ?: return
|
||||
val perms = clubState.currentPermissions
|
||||
val internalState by dependencies.clubInternalTournamentsManager.state.collectAsState()
|
||||
val officialState by dependencies.officialTournamentsReadManager.state.collectAsState()
|
||||
|
||||
if (perms?.canReadTournaments() != true) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(TournamentsPad),
|
||||
) {
|
||||
Text(tr("mobile.noTournamentAccess", "Keine Berechtigung für Turniere."))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LaunchedEffect(clubId, internalState.filter) {
|
||||
dependencies.clubInternalTournamentsManager.loadList(clubId)
|
||||
}
|
||||
|
||||
LaunchedEffect(clubId, internalState.selectedId) {
|
||||
val id = internalState.selectedId ?: return@LaunchedEffect
|
||||
dependencies.clubInternalTournamentsManager.loadDetail(clubId, id)
|
||||
}
|
||||
|
||||
LaunchedEffect(clubId) {
|
||||
dependencies.officialTournamentsReadManager.load(clubId)
|
||||
}
|
||||
|
||||
val participationFlatRows = remember(officialState.participationBuckets) {
|
||||
officialState.participationBuckets.flatMap { bucket ->
|
||||
bucket.entries.map { entry ->
|
||||
ParticipationFlatRow(bucket.tournamentId, bucket.title, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = TournamentsPad, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
tr("navigation.clubTournaments", "Turniere"),
|
||||
style = MaterialTheme.typography.h5,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
tr("mobile.tournamentsHubHint", "Vereins-Turniere und offizielle Meldelisten. Verwaltung im Browser."),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f),
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = { dependencies.openBackendPath("/tournaments") },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = TournamentsTouchMin),
|
||||
) {
|
||||
Text(tr("mobile.openTournamentsInWeb", "Turniere im Browser öffnen"))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text(tr("tournaments.internalTournaments", "Vereins-Turniere"), fontWeight = FontWeight.SemiBold)
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
ModeFilterChip(
|
||||
label = tr("mobile.tournamentFilterInternal", "Intern"),
|
||||
selected = internalState.filter == ClubTournamentDisplayFilter.Internal,
|
||||
onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.Internal) },
|
||||
)
|
||||
ModeFilterChip(
|
||||
label = tr("tournaments.openTournaments", "Offen"),
|
||||
selected = internalState.filter == ClubTournamentDisplayFilter.External,
|
||||
onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.External) },
|
||||
)
|
||||
ModeFilterChip(
|
||||
label = tr("tournaments.miniChampionships", "Mini"),
|
||||
selected = internalState.filter == ClubTournamentDisplayFilter.Mini,
|
||||
onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.Mini) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (internalState.isLoadingList) {
|
||||
item { CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp)) }
|
||||
} else {
|
||||
internalState.error?.let { err ->
|
||||
item {
|
||||
Text(err, color = MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
if (internalState.tournaments.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
tr("mobile.noClubTournaments", "Keine Turniere in dieser Ansicht."),
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(internalState.tournaments, key = { it.id }) { t ->
|
||||
val selected = internalState.selectedId == t.id
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = if (selected) 2.dp else 1.dp,
|
||||
backgroundColor = if (selected) {
|
||||
MaterialTheme.colors.primary.copy(alpha = 0.08f)
|
||||
} else {
|
||||
MaterialTheme.colors.surface
|
||||
},
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
dependencies.clubInternalTournamentsManager.selectTournament(
|
||||
if (selected) null else t.id,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = TournamentsTouchMin),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) {
|
||||
Text(t.name ?: "Turnier #${t.id}", fontWeight = FontWeight.SemiBold)
|
||||
t.date?.let { d -> Text(d, style = MaterialTheme.typography.caption) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (internalState.selectedId != null) {
|
||||
item {
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(tr("mobile.tournamentDetails", "Details"), fontWeight = FontWeight.SemiBold)
|
||||
when {
|
||||
internalState.isLoadingDetail -> CircularProgressIndicator(modifier = Modifier.padding(8.dp))
|
||||
internalState.detail != null -> {
|
||||
val d = internalState.detail!!
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
d.name?.let { Text(it, fontWeight = FontWeight.Medium) }
|
||||
d.date?.let { Text("${tr("tournaments.date", "Datum")}: $it") }
|
||||
d.type?.takeIf { it.isNotBlank() }?.let { Text("${tr("mobile.mode", "Modus")}: $it") }
|
||||
d.winningSets?.let { Text("${tr("tournaments.winningSets", "Gewinnsätze")}: $it") }
|
||||
d.numberOfGroups?.let { Text("${tr("mobile.groups", "Gruppen")}: $it") }
|
||||
d.numberOfTables?.let { Text("${tr("mobile.tables", "Tische")}: $it") }
|
||||
if (d.miniChampionshipYear != null) {
|
||||
Text("${tr("tournaments.miniChampionshipYear", "Minimeisterschaft-Jahr")}: ${d.miniChampionshipYear}")
|
||||
}
|
||||
if (d.allowsExternal == true) {
|
||||
Text(tr("tournaments.openTournaments", "Offenes Turnier"))
|
||||
}
|
||||
if (d.isDoublesTournament == true) {
|
||||
Text(tr("mobile.doublesTournament", "Doppel-Turnier"))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Text(tr("mobile.tournamentDetailPending", "Details werden geladen …"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(tr("officialTournaments.savedEvents", "Offizielle Turniere (Import)"), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
if (officialState.isLoading) {
|
||||
item { CircularProgressIndicator(modifier = Modifier.padding(vertical = 8.dp)) }
|
||||
} else {
|
||||
officialState.error?.let { err ->
|
||||
item { Text(err, color = MaterialTheme.colors.error) }
|
||||
}
|
||||
if (officialState.tournaments.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
tr("officialTournaments.noEvents", "Keine importierten Turniere."),
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(officialState.tournaments, key = { it.id }) { ot ->
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(ot.title ?: "Turnier #${ot.id}", fontWeight = FontWeight.Medium)
|
||||
ot.eventDate?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.caption)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(tr("officialTournaments.participations", "Teilnahmen (übersicht)"), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
if (participationFlatRows.isEmpty() && !officialState.isLoading) {
|
||||
item {
|
||||
Text(
|
||||
tr("mobile.noOfficialParticipations", "Keine erfassten Teilnahmen."),
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
participationFlatRows,
|
||||
key = { r -> "${r.tournamentId}_${r.entry.memberId}_${r.entry.competitionId}_${r.entry.date}_${r.entry.competitionName}" },
|
||||
) { r ->
|
||||
ParticipationRow(tournamentTitle = r.tournamentTitle, entry = r.entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Composable
|
||||
private fun ModeFilterChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = TournamentsTouchMin),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParticipationRow(tournamentTitle: String?, entry: OfficialParticipationEntryDto) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(
|
||||
listOfNotNull(entry.memberName, tournamentTitle).joinToString(" · "),
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
listOfNotNull(entry.competitionName, entry.date, entry.placement?.let { p -> "Pl. $p" })
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.ApiLogDetailDto
|
||||
import de.tt_tagebuch.shared.api.models.ApiLogDetailEnvelopeDto
|
||||
import de.tt_tagebuch.shared.api.models.ApiLogsListEnvelopeDto
|
||||
import de.tt_tagebuch.shared.api.models.ApiLogsListPageDto
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
|
||||
class ApiLogsApi(
|
||||
private val client: AuthedHttpClient,
|
||||
) {
|
||||
suspend fun listLogs(
|
||||
limit: Int = 50,
|
||||
offset: Int = 0,
|
||||
logType: String? = null,
|
||||
method: String? = null,
|
||||
statusCode: Int? = null,
|
||||
pathContains: String? = null,
|
||||
): ApiLogsListPageDto {
|
||||
val env = client.http.get("/api/logs") {
|
||||
parameter("limit", limit)
|
||||
parameter("offset", offset)
|
||||
logType?.takeIf { it.isNotBlank() }?.let { parameter("logType", it) }
|
||||
method?.takeIf { it.isNotBlank() }?.let { parameter("method", it) }
|
||||
statusCode?.let { parameter("statusCode", it) }
|
||||
pathContains?.takeIf { it.isNotBlank() }?.let { parameter("path", it) }
|
||||
}.body<ApiLogsListEnvelopeDto>()
|
||||
return env.data ?: ApiLogsListPageDto()
|
||||
}
|
||||
|
||||
suspend fun getLog(id: Int): ApiLogDetailDto? {
|
||||
val env = client.http.get("/api/logs/$id").body<ApiLogDetailEnvelopeDto>()
|
||||
return env.data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.ClubAccessDecisionBody
|
||||
import de.tt_tagebuch.shared.api.models.PendingUserClubJoinDto
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
|
||||
class ClubApprovalsApi(
|
||||
private val client: AuthedHttpClient,
|
||||
) {
|
||||
suspend fun listPending(clubId: Int): List<PendingUserClubJoinDto> {
|
||||
return client.http.get("/api/clubs/pending/$clubId").body()
|
||||
}
|
||||
|
||||
suspend fun approve(body: ClubAccessDecisionBody) {
|
||||
client.http.post("/api/clubs/approve") { setBody(body) }
|
||||
}
|
||||
|
||||
suspend fun reject(body: ClubAccessDecisionBody) {
|
||||
client.http.post("/api/clubs/reject") { setBody(body) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.ClubTeamDto
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
|
||||
class ClubTeamsApi(
|
||||
private val client: AuthedHttpClient,
|
||||
) {
|
||||
suspend fun listClubTeams(clubId: Int, seasonId: Int? = null): List<ClubTeamDto> {
|
||||
return client.http.get("/api/club-teams/club/$clubId") {
|
||||
seasonId?.let { parameter("seasonid", it) }
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.LeagueTableRowDto
|
||||
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
|
||||
import de.tt_tagebuch.shared.api.models.UpdateMatchPlayersBody
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.setBody
|
||||
|
||||
class MatchesApi(
|
||||
private val client: AuthedHttpClient,
|
||||
) {
|
||||
suspend fun listMatchesForLeagues(clubId: Int, seasonId: Int? = null): List<ScheduleMatchDto> {
|
||||
return client.http.get("/api/matches/leagues/$clubId/matches") {
|
||||
seasonId?.let { parameter("seasonid", it) }
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun listMatchesForLeague(clubId: Int, leagueId: Int, scope: String = "own"): List<ScheduleMatchDto> {
|
||||
return client.http.get("/api/matches/leagues/$clubId/matches/$leagueId") {
|
||||
parameter("scope", scope)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun leagueTable(clubId: Int, leagueId: Int): List<LeagueTableRowDto> {
|
||||
return client.http.get("/api/matches/leagues/$clubId/table/$leagueId").body()
|
||||
}
|
||||
|
||||
suspend fun updateMatchPlayers(matchId: Int, body: UpdateMatchPlayersBody) {
|
||||
client.http.patch("/api/matches/$matchId/players") {
|
||||
setBody(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.OfficialParticipationBucketDto
|
||||
import de.tt_tagebuch.shared.api.models.OfficialTournamentListRowDto
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
|
||||
class OfficialTournamentsApi(
|
||||
private val client: AuthedHttpClient,
|
||||
) {
|
||||
suspend fun listForClub(clubId: Int): List<OfficialTournamentListRowDto> {
|
||||
return client.http.get("/api/official-tournaments/$clubId").body()
|
||||
}
|
||||
|
||||
suspend fun listParticipationSummary(clubId: Int): List<OfficialParticipationBucketDto> {
|
||||
return client.http.get("/api/official-tournaments/$clubId/participations/summary").body()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.AvailableRoleDto
|
||||
import de.tt_tagebuch.shared.api.models.ClubPermissionMemberDto
|
||||
import de.tt_tagebuch.shared.api.models.PermissionResourceDto
|
||||
import de.tt_tagebuch.shared.api.models.UpdateUserApprovedBody
|
||||
import de.tt_tagebuch.shared.api.models.UpdateUserCustomPermissionsBody
|
||||
import de.tt_tagebuch.shared.api.models.UpdateUserRoleBody
|
||||
import de.tt_tagebuch.shared.api.models.UserClubPermissions
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.put
|
||||
import io.ktor.client.request.setBody
|
||||
|
||||
class PermissionsApi(
|
||||
private val client: AuthedHttpClient,
|
||||
@@ -11,5 +20,37 @@ class PermissionsApi(
|
||||
suspend fun getUserPermissions(clubId: Int): UserClubPermissions {
|
||||
return client.http.get("/api/permissions/$clubId").body()
|
||||
}
|
||||
|
||||
suspend fun listAvailableRoles(): List<AvailableRoleDto> {
|
||||
return client.http.get("/api/permissions/roles/available").body()
|
||||
}
|
||||
|
||||
suspend fun getPermissionStructure(): Map<String, PermissionResourceDto> {
|
||||
return client.http.get("/api/permissions/structure/all").body()
|
||||
}
|
||||
|
||||
suspend fun listClubMembersWithPermissions(clubId: Int, cacheBust: Boolean = false): List<ClubPermissionMemberDto> {
|
||||
return client.http.get("/api/permissions/$clubId/members") {
|
||||
if (cacheBust) parameter("t", kotlin.random.Random.nextLong().toString())
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun updateUserRole(clubId: Int, userId: Int, role: String) {
|
||||
client.http.put("/api/permissions/$clubId/user/$userId/role") {
|
||||
setBody(UpdateUserRoleBody(role = role))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateUserStatus(clubId: Int, userId: Int, approved: Boolean) {
|
||||
client.http.put("/api/permissions/$clubId/user/$userId/status") {
|
||||
setBody(UpdateUserApprovedBody(approved = approved))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateUserCustomPermissions(clubId: Int, userId: Int, permissions: kotlinx.serialization.json.JsonObject) {
|
||||
client.http.put("/api/permissions/$clubId/user/$userId/permissions") {
|
||||
setBody(UpdateUserCustomPermissionsBody(permissions = permissions))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
/**
|
||||
* Rollen-Standardrechte (analog [backend/services/permissionService.js] ROLE_PERMISSIONS)
|
||||
* für die mobile Berechtigungs-UI (Erbe / explizit erlauben / explizit verbieten).
|
||||
*/
|
||||
object RolePermissionMatrix {
|
||||
|
||||
private val admin = resourceMap(
|
||||
diary = triple(true, true, true),
|
||||
members = triple(true, true, true),
|
||||
teams = triple(true, true, true),
|
||||
schedule = triple(true, true, true),
|
||||
tournaments = triple(true, true, true),
|
||||
statistics = pair(true, true),
|
||||
settings = pair(true, true),
|
||||
permissions = pair(true, true),
|
||||
approvals = pair(true, true),
|
||||
mytischtennis_admin = pair(true, true),
|
||||
predefined_activities = triple(true, true, true),
|
||||
)
|
||||
|
||||
private val trainer = resourceMap(
|
||||
diary = triple(true, true, true),
|
||||
members = triple(true, true, false),
|
||||
teams = triple(true, true, false),
|
||||
schedule = triple(true, false, false),
|
||||
tournaments = triple(true, true, false),
|
||||
statistics = pair(true, false),
|
||||
settings = pair(false, false),
|
||||
permissions = pair(false, false),
|
||||
approvals = pair(false, false),
|
||||
mytischtennis_admin = pair(false, false),
|
||||
predefined_activities = triple(true, true, true),
|
||||
)
|
||||
|
||||
private val teamManager = resourceMap(
|
||||
diary = triple(false, false, false),
|
||||
members = triple(true, false, false),
|
||||
teams = triple(true, true, false),
|
||||
schedule = triple(true, true, false),
|
||||
tournaments = triple(true, false, false),
|
||||
statistics = pair(true, false),
|
||||
settings = pair(false, false),
|
||||
permissions = pair(false, false),
|
||||
approvals = pair(false, false),
|
||||
mytischtennis_admin = pair(false, false),
|
||||
predefined_activities = triple(false, false, false),
|
||||
)
|
||||
|
||||
private val tournamentManager = resourceMap(
|
||||
diary = triple(false, false, false),
|
||||
members = triple(true, false, false),
|
||||
teams = triple(false, false, false),
|
||||
schedule = triple(false, false, false),
|
||||
tournaments = triple(true, true, false),
|
||||
statistics = pair(true, false),
|
||||
settings = pair(false, false),
|
||||
permissions = pair(false, false),
|
||||
approvals = pair(false, false),
|
||||
mytischtennis_admin = pair(false, false),
|
||||
predefined_activities = triple(false, false, false),
|
||||
)
|
||||
|
||||
private val member = resourceMap(
|
||||
diary = triple(false, false, false),
|
||||
members = triple(false, false, false),
|
||||
teams = triple(false, false, false),
|
||||
schedule = triple(false, false, false),
|
||||
tournaments = triple(false, false, false),
|
||||
statistics = pair(true, false),
|
||||
settings = pair(false, false),
|
||||
permissions = pair(false, false),
|
||||
approvals = pair(false, false),
|
||||
mytischtennis_admin = pair(false, false),
|
||||
predefined_activities = triple(false, false, false),
|
||||
)
|
||||
|
||||
private fun triple(r: Boolean, w: Boolean, d: Boolean): Map<String, Boolean> =
|
||||
mapOf("read" to r, "write" to w, "delete" to d)
|
||||
|
||||
private fun pair(r: Boolean, w: Boolean): Map<String, Boolean> =
|
||||
mapOf("read" to r, "write" to w)
|
||||
|
||||
private fun resourceMap(
|
||||
diary: Map<String, Boolean>,
|
||||
members: Map<String, Boolean>,
|
||||
teams: Map<String, Boolean>,
|
||||
schedule: Map<String, Boolean>,
|
||||
tournaments: Map<String, Boolean>,
|
||||
statistics: Map<String, Boolean>,
|
||||
settings: Map<String, Boolean>,
|
||||
permissions: Map<String, Boolean>,
|
||||
approvals: Map<String, Boolean>,
|
||||
mytischtennis_admin: Map<String, Boolean>,
|
||||
predefined_activities: Map<String, Boolean>,
|
||||
): Map<String, Map<String, Boolean>> = mapOf(
|
||||
"diary" to diary,
|
||||
"members" to members,
|
||||
"teams" to teams,
|
||||
"schedule" to schedule,
|
||||
"tournaments" to tournaments,
|
||||
"statistics" to statistics,
|
||||
"settings" to settings,
|
||||
"permissions" to permissions,
|
||||
"approvals" to approvals,
|
||||
"mytischtennis_admin" to mytischtennis_admin,
|
||||
"predefined_activities" to predefined_activities,
|
||||
)
|
||||
|
||||
fun defaultsForRole(role: String): Map<String, Map<String, Boolean>> =
|
||||
when (role) {
|
||||
"admin" -> admin
|
||||
"trainer" -> trainer
|
||||
"team_manager" -> teamManager
|
||||
"tournament_manager" -> tournamentManager
|
||||
else -> member
|
||||
}
|
||||
|
||||
fun defaultAction(role: String, resource: String, action: String): Boolean =
|
||||
defaultsForRole(role)[resource]?.get(action) ?: false
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.models.ClubTeamDto
|
||||
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
|
||||
import de.tt_tagebuch.shared.api.models.ScheduleMatchScope
|
||||
|
||||
object ScheduleLogic {
|
||||
|
||||
fun sortClubTeams(teams: List<ClubTeamDto>): List<ClubTeamDto> =
|
||||
teams.sortedWith(compareBy({ it.league?.name ?: "" }, { it.name }))
|
||||
|
||||
fun sortMatches(matches: List<ScheduleMatchDto>): List<ScheduleMatchDto> =
|
||||
matches.sortedWith(
|
||||
compareBy(
|
||||
{ it.date ?: "" },
|
||||
{ it.time ?: "" },
|
||||
{ it.homeTeam?.name ?: "" },
|
||||
{ it.guestTeam?.name ?: "" },
|
||||
),
|
||||
)
|
||||
|
||||
fun mergeUniqueMatches(a: List<ScheduleMatchDto>, b: List<ScheduleMatchDto>): List<ScheduleMatchDto> {
|
||||
val seen = LinkedHashSet<Int>()
|
||||
val out = ArrayList<ScheduleMatchDto>(a.size + b.size)
|
||||
for (m in a + b) {
|
||||
if (seen.add(m.id)) out.add(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun leagueTeamNames(matches: List<ScheduleMatchDto>): List<String> {
|
||||
val names = LinkedHashSet<String>()
|
||||
for (m in matches) {
|
||||
m.homeTeam?.name?.takeIf { it.isNotBlank() }?.let(names::add)
|
||||
m.guestTeam?.name?.takeIf { it.isNotBlank() }?.let(names::add)
|
||||
}
|
||||
return names.sorted()
|
||||
}
|
||||
|
||||
fun filterAdultLeagues(matches: List<ScheduleMatchDto>): List<ScheduleMatchDto> {
|
||||
val youth = Regex("""[JM]\d|jugend""", RegexOption.IGNORE_CASE)
|
||||
return matches.filter { m ->
|
||||
val leagueName = m.leagueDetails?.name ?: ""
|
||||
!youth.containsMatchIn(leagueName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ownTeamName Name des gewählten Vereins-Teams (ClubTeam), wie im Web `selectedTeam.name`.
|
||||
*/
|
||||
fun applyTeamMatchScope(
|
||||
ownMatches: List<ScheduleMatchDto>,
|
||||
allMatches: List<ScheduleMatchDto>,
|
||||
scope: ScheduleMatchScope,
|
||||
ownTeamName: String,
|
||||
otherTeamName: String,
|
||||
): List<ScheduleMatchDto> {
|
||||
val combined = sortMatches(mergeUniqueMatches(allMatches, ownMatches))
|
||||
return when (scope) {
|
||||
ScheduleMatchScope.All -> combined
|
||||
ScheduleMatchScope.Other -> {
|
||||
if (otherTeamName.isBlank()) emptyList()
|
||||
else combined.filter { m ->
|
||||
m.homeTeam?.name == otherTeamName || m.guestTeam?.name == otherTeamName
|
||||
}
|
||||
}
|
||||
ScheduleMatchScope.Own ->
|
||||
if (ownMatches.isNotEmpty()) sortMatches(ownMatches)
|
||||
else combined.filter { m ->
|
||||
m.homeTeam?.name == ownTeamName || m.guestTeam?.name == ownTeamName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun teamsWithLeague(teams: List<ClubTeamDto>): List<ClubTeamDto> =
|
||||
teams.filter { it.league != null && (it.league?.id ?: 0) > 0 }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.InternalTournamentDetailDto
|
||||
import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
|
||||
class TournamentsApi(
|
||||
private val client: AuthedHttpClient,
|
||||
) {
|
||||
/** Query [type]: `mini` nur Minimeisterschaften; sonst alle (clientseitig nach intern/offen filtern). */
|
||||
suspend fun listTournaments(clubId: Int, type: String? = null): List<InternalTournamentSummaryDto> {
|
||||
return client.http.get("/api/tournament/$clubId") {
|
||||
type?.takeIf { it.isNotBlank() }?.let { parameter("type", it) }
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun getTournament(clubId: Int, tournamentId: Int): InternalTournamentDetailDto {
|
||||
return client.http.get("/api/tournament/$clubId/$tournamentId").body()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@Serializable
|
||||
data class PendingUserDto(
|
||||
val id: Int = 0,
|
||||
val email: String = "",
|
||||
val firstName: String? = null,
|
||||
val lastName: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PendingUserClubJoinDto(
|
||||
val id: Int? = null,
|
||||
val userId: Int = 0,
|
||||
val clubId: Int? = null,
|
||||
val user: PendingUserDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ClubAccessDecisionBody(
|
||||
val clubid: Int,
|
||||
val userid: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AvailableRoleDto(
|
||||
val value: String,
|
||||
val label: String,
|
||||
val description: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PermissionResourceDto(
|
||||
val label: String = "",
|
||||
val actions: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PermissionUserRefDto(
|
||||
val id: Int = 0,
|
||||
val email: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ClubPermissionMemberDto(
|
||||
val userId: Int,
|
||||
val user: PermissionUserRefDto? = null,
|
||||
val role: String = "",
|
||||
val isOwner: Boolean = false,
|
||||
val approved: Boolean? = true,
|
||||
val permissions: JsonObject? = null,
|
||||
val effectivePermissions: JsonObject? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateUserRoleBody(
|
||||
val role: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateUserApprovedBody(
|
||||
val approved: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateUserCustomPermissionsBody(
|
||||
val permissions: JsonObject,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiLogListRowDto(
|
||||
val id: Int,
|
||||
val userId: Int? = null,
|
||||
val method: String? = null,
|
||||
val path: String? = null,
|
||||
val statusCode: Int? = null,
|
||||
val executionTime: Int? = null,
|
||||
val errorMessage: String? = null,
|
||||
val logType: String? = null,
|
||||
val schedulerJobType: String? = null,
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiLogsListPageDto(
|
||||
val logs: List<ApiLogListRowDto> = emptyList(),
|
||||
val total: Int = 0,
|
||||
val limit: Int = 0,
|
||||
val offset: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiLogsListEnvelopeDto(
|
||||
val success: Boolean = false,
|
||||
val data: ApiLogsListPageDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiLogDetailEnvelopeDto(
|
||||
val success: Boolean = false,
|
||||
val data: ApiLogDetailDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiLogDetailDto(
|
||||
val id: Int = 0,
|
||||
val userId: Int? = null,
|
||||
val method: String? = null,
|
||||
val path: String? = null,
|
||||
val statusCode: Int? = null,
|
||||
val executionTime: Int? = null,
|
||||
val errorMessage: String? = null,
|
||||
val logType: String? = null,
|
||||
val schedulerJobType: String? = null,
|
||||
val createdAt: String? = null,
|
||||
val ipAddress: String? = null,
|
||||
val userAgent: String? = null,
|
||||
)
|
||||
@@ -30,3 +30,54 @@ fun UserClubPermissions.canWriteMembers(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("members", "write")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canReadSchedule(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("schedule", "read")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canWriteSchedule(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("schedule", "write")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canReadApprovals(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("approvals", "read")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canWriteApprovals(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("approvals", "write")
|
||||
}
|
||||
|
||||
/** Lesen der Berechtigungsverwaltung (Modul `permissions` im Backend). */
|
||||
fun UserClubPermissions.canReadClubPermissions(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("permissions", "read")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canWriteClubPermissions(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("permissions", "write")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canReadTeams(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("teams", "read")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canWriteTeams(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("teams", "write")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canReadTournaments(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("tournaments", "read")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canWriteTournaments(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("tournaments", "write")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ClubTeamLeagueDto(
|
||||
val id: Int = 0,
|
||||
val name: String = "",
|
||||
val myTischtennisGroupId: String? = null,
|
||||
val association: String? = null,
|
||||
val groupname: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ClubTeamSeasonDto(
|
||||
val season: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ClubTeamDto(
|
||||
val id: Int,
|
||||
val name: String = "",
|
||||
val clubId: Int = 0,
|
||||
val leagueId: Int? = null,
|
||||
val seasonId: Int? = null,
|
||||
val myTischtennisTeamId: String? = null,
|
||||
val teamGender: String? = null,
|
||||
val teamAgeGroup: String? = null,
|
||||
val plannedLeagueName: String? = null,
|
||||
val league: ClubTeamLeagueDto? = null,
|
||||
val season: ClubTeamSeasonDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScheduleTeamNameDto(
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScheduleLocationDto(
|
||||
val name: String = "",
|
||||
val address: String = "",
|
||||
val city: String = "",
|
||||
val zip: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScheduleLeagueDetailsDto(
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScheduleMatchDto(
|
||||
val id: Int,
|
||||
val date: String? = null,
|
||||
val time: String? = null,
|
||||
val homeTeamId: Int? = null,
|
||||
val guestTeamId: Int? = null,
|
||||
val locationId: Int? = null,
|
||||
val leagueId: Int? = null,
|
||||
val code: String? = null,
|
||||
val homePin: String? = null,
|
||||
val guestPin: String? = null,
|
||||
val homeMatchPoints: Int = 0,
|
||||
val guestMatchPoints: Int = 0,
|
||||
val isCompleted: Boolean = false,
|
||||
val pdfUrl: String? = null,
|
||||
val playersReady: List<Int> = emptyList(),
|
||||
val playersPlanned: List<Int> = emptyList(),
|
||||
val playersPlayed: List<Int> = emptyList(),
|
||||
val homeTeam: ScheduleTeamNameDto? = null,
|
||||
val guestTeam: ScheduleTeamNameDto? = null,
|
||||
val location: ScheduleLocationDto? = null,
|
||||
val leagueDetails: ScheduleLeagueDetailsDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LeagueTableRowDto(
|
||||
val teamId: Int,
|
||||
val teamName: String = "",
|
||||
val setsWon: Int = 0,
|
||||
val setsLost: Int = 0,
|
||||
/** z. B. \"3:1\" */
|
||||
val matchPoints: String = "",
|
||||
val tablePoints: String = "",
|
||||
val pointRatio: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateMatchPlayersBody(
|
||||
val clubId: Int,
|
||||
val playersReady: List<Int> = emptyList(),
|
||||
val playersPlanned: List<Int> = emptyList(),
|
||||
val playersPlayed: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
enum class ScheduleMatchScope {
|
||||
Own,
|
||||
All,
|
||||
Other,
|
||||
}
|
||||
|
||||
enum class ScheduleViewMode {
|
||||
Team,
|
||||
Overall,
|
||||
Adult,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class InternalTournamentSummaryDto(
|
||||
val id: Int = 0,
|
||||
val name: String? = null,
|
||||
val date: String? = null,
|
||||
val allowsExternal: Boolean? = null,
|
||||
val miniChampionshipYear: Int? = null,
|
||||
val isDoublesTournament: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class InternalTournamentDetailDto(
|
||||
val id: Int = 0,
|
||||
val name: String? = null,
|
||||
val date: String? = null,
|
||||
val type: String? = null,
|
||||
val clubId: Int? = null,
|
||||
val winningSets: Int? = null,
|
||||
val allowsExternal: Boolean? = null,
|
||||
val miniChampionshipYear: Int? = null,
|
||||
val numberOfTables: Int? = null,
|
||||
val numberOfGroups: Int? = null,
|
||||
val advancingPerGroup: Int? = null,
|
||||
val isDoublesTournament: Boolean? = null,
|
||||
val bestOfEndroundSize: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OfficialTournamentListRowDto(
|
||||
val id: Int = 0,
|
||||
val clubId: Int? = null,
|
||||
val title: String? = null,
|
||||
val eventDate: String? = null,
|
||||
val organizer: String? = null,
|
||||
val host: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OfficialParticipationEntryDto(
|
||||
val memberId: Int? = null,
|
||||
val memberName: String? = null,
|
||||
val competitionId: Int? = null,
|
||||
val competitionName: String? = null,
|
||||
val placement: String? = null,
|
||||
val date: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OfficialParticipationBucketDto(
|
||||
val tournamentId: String? = null,
|
||||
val title: String? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val entries: List<OfficialParticipationEntryDto> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
import de.tt_tagebuch.shared.api.ApiLogsApi
|
||||
import de.tt_tagebuch.shared.api.models.ApiLogDetailDto
|
||||
import de.tt_tagebuch.shared.api.models.ApiLogListRowDto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class ApiLogsState(
|
||||
val logs: List<ApiLogListRowDto> = emptyList(),
|
||||
val total: Int = 0,
|
||||
val offset: Int = 0,
|
||||
val limit: Int = 50,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class ApiLogsManager(
|
||||
private val apiLogsApi: ApiLogsApi,
|
||||
) {
|
||||
private val _state = MutableStateFlow(ApiLogsState())
|
||||
val state: StateFlow<ApiLogsState> = _state.asStateFlow()
|
||||
|
||||
fun clear() {
|
||||
_state.value = ApiLogsState()
|
||||
}
|
||||
|
||||
suspend fun load(
|
||||
logType: String? = null,
|
||||
method: String? = null,
|
||||
statusCode: Int? = null,
|
||||
pathContains: String? = null,
|
||||
resetOffset: Boolean = false,
|
||||
offsetOverride: Int? = null,
|
||||
) {
|
||||
val offset = offsetOverride ?: if (resetOffset) 0 else _state.value.offset
|
||||
val limit = _state.value.limit
|
||||
_state.update { it.copy(isLoading = true, error = null, offset = offset) }
|
||||
try {
|
||||
val page = apiLogsApi.listLogs(
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
logType = logType,
|
||||
method = method,
|
||||
statusCode = statusCode,
|
||||
pathContains = pathContains,
|
||||
)
|
||||
_state.update {
|
||||
it.copy(
|
||||
logs = page.logs,
|
||||
total = page.total,
|
||||
limit = page.limit.takeIf { l -> l > 0 } ?: limit,
|
||||
offset = page.offset,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = t.toUserMessage("Logs konnten nicht geladen werden"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nextPage(
|
||||
logType: String? = null,
|
||||
method: String? = null,
|
||||
statusCode: Int? = null,
|
||||
pathContains: String? = null,
|
||||
) {
|
||||
val s = _state.value
|
||||
if (s.offset + s.logs.size >= s.total) return
|
||||
load(logType, method, statusCode, pathContains, offsetOverride = s.offset + s.limit)
|
||||
}
|
||||
|
||||
suspend fun previousPage(
|
||||
logType: String? = null,
|
||||
method: String? = null,
|
||||
statusCode: Int? = null,
|
||||
pathContains: String? = null,
|
||||
) {
|
||||
val s = _state.value
|
||||
if (s.offset <= 0) return
|
||||
load(logType, method, statusCode, pathContains, offsetOverride = (s.offset - s.limit).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
suspend fun fetchDetail(id: Int): ApiLogDetailDto? =
|
||||
try {
|
||||
apiLogsApi.getLog(id)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
import de.tt_tagebuch.shared.api.TournamentsApi
|
||||
import de.tt_tagebuch.shared.api.models.InternalTournamentDetailDto
|
||||
import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
enum class ClubTournamentDisplayFilter {
|
||||
Internal,
|
||||
External,
|
||||
Mini,
|
||||
}
|
||||
|
||||
data class ClubInternalTournamentsState(
|
||||
val filter: ClubTournamentDisplayFilter = ClubTournamentDisplayFilter.Internal,
|
||||
val tournaments: List<InternalTournamentSummaryDto> = emptyList(),
|
||||
val selectedId: Int? = null,
|
||||
val detail: InternalTournamentDetailDto? = null,
|
||||
val isLoadingList: Boolean = false,
|
||||
val isLoadingDetail: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class ClubInternalTournamentsManager(
|
||||
private val tournamentsApi: TournamentsApi,
|
||||
) {
|
||||
private val _state = MutableStateFlow(ClubInternalTournamentsState())
|
||||
val state: StateFlow<ClubInternalTournamentsState> = _state.asStateFlow()
|
||||
|
||||
fun clear() {
|
||||
_state.value = ClubInternalTournamentsState()
|
||||
}
|
||||
|
||||
fun setFilter(filter: ClubTournamentDisplayFilter) {
|
||||
_state.update { it.copy(filter = filter, selectedId = null, detail = null, error = null) }
|
||||
}
|
||||
|
||||
fun selectTournament(id: Int?) {
|
||||
_state.update { it.copy(selectedId = id, detail = null) }
|
||||
}
|
||||
|
||||
suspend fun loadList(clubId: Int) {
|
||||
val filter = _state.value.filter
|
||||
_state.update { it.copy(isLoadingList = true, error = null) }
|
||||
try {
|
||||
val raw = when (filter) {
|
||||
ClubTournamentDisplayFilter.Mini -> tournamentsApi.listTournaments(clubId, type = "mini")
|
||||
else -> tournamentsApi.listTournaments(clubId, type = null)
|
||||
}
|
||||
val list = when (filter) {
|
||||
ClubTournamentDisplayFilter.Mini -> raw
|
||||
ClubTournamentDisplayFilter.Internal ->
|
||||
raw.filter { it.miniChampionshipYear == null && it.allowsExternal != true }
|
||||
ClubTournamentDisplayFilter.External ->
|
||||
raw.filter { it.miniChampionshipYear == null && it.allowsExternal == true }
|
||||
}
|
||||
_state.update { it.copy(tournaments = list, isLoadingList = false, error = null) }
|
||||
} catch (t: Throwable) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoadingList = false,
|
||||
error = t.toUserMessage("Vereins-Turniere konnten nicht geladen werden"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadDetail(clubId: Int, tournamentId: Int) {
|
||||
_state.update { it.copy(isLoadingDetail = true, error = null) }
|
||||
try {
|
||||
val d = tournamentsApi.getTournament(clubId, tournamentId)
|
||||
_state.update { it.copy(detail = d, isLoadingDetail = false, error = null) }
|
||||
} catch (t: Throwable) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoadingDetail = false,
|
||||
error = t.toUserMessage("Turnierdetails konnten nicht geladen werden"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
import de.tt_tagebuch.shared.api.OfficialTournamentsApi
|
||||
import de.tt_tagebuch.shared.api.models.OfficialParticipationBucketDto
|
||||
import de.tt_tagebuch.shared.api.models.OfficialTournamentListRowDto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class OfficialTournamentsReadState(
|
||||
val tournaments: List<OfficialTournamentListRowDto> = emptyList(),
|
||||
val participationBuckets: List<OfficialParticipationBucketDto> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class OfficialTournamentsReadManager(
|
||||
private val api: OfficialTournamentsApi,
|
||||
) {
|
||||
private val _state = MutableStateFlow(OfficialTournamentsReadState())
|
||||
val state: StateFlow<OfficialTournamentsReadState> = _state.asStateFlow()
|
||||
|
||||
fun clear() {
|
||||
_state.value = OfficialTournamentsReadState()
|
||||
}
|
||||
|
||||
suspend fun load(clubId: Int) {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val list = api.listForClub(clubId)
|
||||
val summary = api.listParticipationSummary(clubId)
|
||||
_state.update {
|
||||
it.copy(
|
||||
tournaments = list,
|
||||
participationBuckets = summary,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = t.toUserMessage("Offizielle Turniere konnten nicht geladen werden"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
import de.tt_tagebuch.shared.api.ClubApprovalsApi
|
||||
import de.tt_tagebuch.shared.api.models.ClubAccessDecisionBody
|
||||
import de.tt_tagebuch.shared.api.models.PendingUserClubJoinDto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class PendingApprovalsState(
|
||||
val pending: List<PendingUserClubJoinDto> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PendingApprovalsManager(
|
||||
private val clubApprovalsApi: ClubApprovalsApi,
|
||||
) {
|
||||
private val _state = MutableStateFlow(PendingApprovalsState())
|
||||
val state: StateFlow<PendingApprovalsState> = _state.asStateFlow()
|
||||
|
||||
fun clear() {
|
||||
_state.value = PendingApprovalsState()
|
||||
}
|
||||
|
||||
suspend fun load(clubId: Int) {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val list = clubApprovalsApi.listPending(clubId)
|
||||
_state.update { it.copy(pending = list, isLoading = false, error = null) }
|
||||
} catch (t: Throwable) {
|
||||
_state.update {
|
||||
it.copy(isLoading = false, error = t.toUserMessage("Freigaben konnten nicht geladen werden"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun approve(clubId: Int, userId: Int) {
|
||||
clubApprovalsApi.approve(ClubAccessDecisionBody(clubid = clubId, userid = userId))
|
||||
load(clubId)
|
||||
}
|
||||
|
||||
suspend fun reject(clubId: Int, userId: Int) {
|
||||
clubApprovalsApi.reject(ClubAccessDecisionBody(clubid = clubId, userid = userId))
|
||||
load(clubId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
import de.tt_tagebuch.shared.api.PermissionsApi
|
||||
import de.tt_tagebuch.shared.api.models.AvailableRoleDto
|
||||
import de.tt_tagebuch.shared.api.models.ClubPermissionMemberDto
|
||||
import de.tt_tagebuch.shared.api.models.PermissionResourceDto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class PermissionsAdminState(
|
||||
val members: List<ClubPermissionMemberDto> = emptyList(),
|
||||
val availableRoles: List<AvailableRoleDto> = emptyList(),
|
||||
val permissionStructure: Map<String, PermissionResourceDto> = emptyMap(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PermissionsAdminManager(
|
||||
private val permissionsApi: PermissionsApi,
|
||||
) {
|
||||
private val _state = MutableStateFlow(PermissionsAdminState())
|
||||
val state: StateFlow<PermissionsAdminState> = _state.asStateFlow()
|
||||
|
||||
fun clear() {
|
||||
_state.value = PermissionsAdminState()
|
||||
}
|
||||
|
||||
suspend fun load(clubId: Int) {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val roles = permissionsApi.listAvailableRoles()
|
||||
val structure = permissionsApi.getPermissionStructure()
|
||||
val members = permissionsApi.listClubMembersWithPermissions(clubId)
|
||||
_state.update {
|
||||
it.copy(
|
||||
availableRoles = roles,
|
||||
permissionStructure = structure,
|
||||
members = members,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = t.toUserMessage("Berechtigungen konnten nicht geladen werden"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reloadMembers(clubId: Int) {
|
||||
try {
|
||||
val members = permissionsApi.listClubMembersWithPermissions(clubId, cacheBust = true)
|
||||
_state.update { it.copy(members = members) }
|
||||
} catch (_: Throwable) { }
|
||||
}
|
||||
|
||||
suspend fun updateRole(clubId: Int, userId: Int, role: String) {
|
||||
permissionsApi.updateUserRole(clubId, userId, role)
|
||||
load(clubId)
|
||||
}
|
||||
|
||||
suspend fun updateApproved(clubId: Int, userId: Int, approved: Boolean) {
|
||||
permissionsApi.updateUserStatus(clubId, userId, approved)
|
||||
load(clubId)
|
||||
}
|
||||
|
||||
suspend fun saveCustomPermissions(clubId: Int, userId: Int, permissions: kotlinx.serialization.json.JsonObject) {
|
||||
permissionsApi.updateUserCustomPermissions(clubId, userId, permissions)
|
||||
load(clubId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
import de.tt_tagebuch.shared.api.ClubTeamsApi
|
||||
import de.tt_tagebuch.shared.api.MatchesApi
|
||||
import de.tt_tagebuch.shared.api.ScheduleLogic
|
||||
import de.tt_tagebuch.shared.api.models.ClubTeamDto
|
||||
import de.tt_tagebuch.shared.api.models.LeagueTableRowDto
|
||||
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
|
||||
import de.tt_tagebuch.shared.api.models.ScheduleMatchScope
|
||||
import de.tt_tagebuch.shared.api.models.ScheduleViewMode
|
||||
import de.tt_tagebuch.shared.api.models.UpdateMatchPlayersBody
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class ScheduleState(
|
||||
val viewMode: ScheduleViewMode = ScheduleViewMode.Team,
|
||||
val teams: List<ClubTeamDto> = emptyList(),
|
||||
val selectedTeamId: Int? = null,
|
||||
val ownMatches: List<ScheduleMatchDto> = emptyList(),
|
||||
val allMatches: List<ScheduleMatchDto> = emptyList(),
|
||||
val overallMatches: List<ScheduleMatchDto> = emptyList(),
|
||||
val leagueTable: List<LeagueTableRowDto> = emptyList(),
|
||||
val matchScope: ScheduleMatchScope = ScheduleMatchScope.Own,
|
||||
val otherTeamName: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val seasonId: Int? = null,
|
||||
) {
|
||||
val selectedTeam: ClubTeamDto?
|
||||
get() = teams.find { it.id == selectedTeamId }
|
||||
|
||||
val displayedMatches: List<ScheduleMatchDto>
|
||||
get() = when (viewMode) {
|
||||
ScheduleViewMode.Overall -> ScheduleLogic.sortMatches(overallMatches)
|
||||
ScheduleViewMode.Adult ->
|
||||
ScheduleLogic.sortMatches(ScheduleLogic.filterAdultLeagues(overallMatches))
|
||||
ScheduleViewMode.Team -> {
|
||||
val t = selectedTeam ?: return emptyList()
|
||||
ScheduleLogic.applyTeamMatchScope(
|
||||
ownMatches = ownMatches,
|
||||
allMatches = allMatches,
|
||||
scope = matchScope,
|
||||
ownTeamName = t.name,
|
||||
otherTeamName = otherTeamName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val leagueTeamOptions: List<String>
|
||||
get() = ScheduleLogic.leagueTeamNames(ScheduleLogic.mergeUniqueMatches(allMatches, ownMatches))
|
||||
}
|
||||
|
||||
class ScheduleManager(
|
||||
private val clubTeamsApi: ClubTeamsApi,
|
||||
private val matchesApi: MatchesApi,
|
||||
) {
|
||||
private val _state = MutableStateFlow(ScheduleState())
|
||||
val state: StateFlow<ScheduleState> = _state.asStateFlow()
|
||||
|
||||
fun clear() {
|
||||
_state.value = ScheduleState()
|
||||
}
|
||||
|
||||
suspend fun refresh(clubId: Int) {
|
||||
when (_state.value.viewMode) {
|
||||
ScheduleViewMode.Team -> {
|
||||
val team = _state.value.selectedTeam
|
||||
if (team != null && (team.league?.id ?: 0) > 0) {
|
||||
loadMatchesForTeam(clubId, team)
|
||||
} else {
|
||||
loadClubTeams(clubId)
|
||||
}
|
||||
}
|
||||
ScheduleViewMode.Overall -> loadOverallSchedule(clubId)
|
||||
ScheduleViewMode.Adult -> loadAdultSchedule(clubId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadClubTeams(clubId: Int) {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val raw = clubTeamsApi.listClubTeams(clubId, _state.value.seasonId)
|
||||
val sorted = ScheduleLogic.sortClubTeams(raw)
|
||||
val withLeague = ScheduleLogic.teamsWithLeague(sorted)
|
||||
val pick = withLeague.firstOrNull() ?: sorted.firstOrNull()
|
||||
_state.update {
|
||||
it.copy(
|
||||
teams = sorted,
|
||||
selectedTeamId = pick?.id,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
if (pick != null && (pick.league?.id ?: 0) > 0) {
|
||||
loadMatchesForTeam(clubId, pick)
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
viewMode = ScheduleViewMode.Team,
|
||||
ownMatches = emptyList(),
|
||||
allMatches = emptyList(),
|
||||
leagueTable = emptyList(),
|
||||
overallMatches = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = t.toUserMessage("Mannschaften konnten nicht geladen werden"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun selectTeam(clubId: Int, teamId: Int) {
|
||||
val team = _state.value.teams.find { it.id == teamId } ?: return
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedTeamId = teamId,
|
||||
matchScope = ScheduleMatchScope.Own,
|
||||
otherTeamName = "",
|
||||
)
|
||||
}
|
||||
if ((team.league?.id ?: 0) > 0) {
|
||||
loadMatchesForTeam(clubId, team)
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
ownMatches = emptyList(),
|
||||
allMatches = emptyList(),
|
||||
leagueTable = emptyList(),
|
||||
overallMatches = emptyList(),
|
||||
viewMode = ScheduleViewMode.Team,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadMatchesForTeam(clubId: Int, team: ClubTeamDto) {
|
||||
val leagueId = team.league?.id ?: return
|
||||
_state.update { it.copy(isLoading = true, error = null, viewMode = ScheduleViewMode.Team) }
|
||||
try {
|
||||
val own = matchesApi.listMatchesForLeague(clubId, leagueId, "own")
|
||||
val all = matchesApi.listMatchesForLeague(clubId, leagueId, "all")
|
||||
val table = runCatching { matchesApi.leagueTable(clubId, leagueId) }.getOrElse { emptyList() }
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedTeamId = team.id,
|
||||
ownMatches = own,
|
||||
allMatches = all,
|
||||
overallMatches = emptyList(),
|
||||
leagueTable = table,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
ensureOtherTeamDefault()
|
||||
} catch (t: Throwable) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
ownMatches = emptyList(),
|
||||
allMatches = emptyList(),
|
||||
leagueTable = emptyList(),
|
||||
error = t.toUserMessage("Spiele konnten nicht geladen werden"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadOverallSchedule(clubId: Int) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
error = null,
|
||||
viewMode = ScheduleViewMode.Overall,
|
||||
matchScope = ScheduleMatchScope.Own,
|
||||
otherTeamName = "",
|
||||
selectedTeamId = null,
|
||||
ownMatches = emptyList(),
|
||||
allMatches = emptyList(),
|
||||
leagueTable = emptyList(),
|
||||
)
|
||||
}
|
||||
try {
|
||||
val matches = matchesApi.listMatchesForLeagues(clubId, _state.value.seasonId)
|
||||
_state.update { it.copy(overallMatches = matches, isLoading = false, error = null) }
|
||||
} catch (t: Throwable) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
overallMatches = emptyList(),
|
||||
error = t.toUserMessage("Gesamtspielplan konnte nicht geladen werden"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadAdultSchedule(clubId: Int) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
error = null,
|
||||
viewMode = ScheduleViewMode.Adult,
|
||||
matchScope = ScheduleMatchScope.Own,
|
||||
otherTeamName = "",
|
||||
selectedTeamId = null,
|
||||
ownMatches = emptyList(),
|
||||
allMatches = emptyList(),
|
||||
leagueTable = emptyList(),
|
||||
)
|
||||
}
|
||||
try {
|
||||
val matches = matchesApi.listMatchesForLeagues(clubId, _state.value.seasonId)
|
||||
_state.update { it.copy(overallMatches = matches, isLoading = false, error = null) }
|
||||
} catch (t: Throwable) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
overallMatches = emptyList(),
|
||||
error = t.toUserMessage("Erwachsenen-Spielplan konnte nicht geladen werden"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setMatchScope(scope: ScheduleMatchScope) {
|
||||
_state.update { it.copy(matchScope = scope) }
|
||||
if (scope == ScheduleMatchScope.Other) {
|
||||
ensureOtherTeamDefault()
|
||||
} else {
|
||||
_state.update { it.copy(otherTeamName = "") }
|
||||
}
|
||||
}
|
||||
|
||||
fun setOtherTeamName(name: String) {
|
||||
_state.update { it.copy(otherTeamName = name) }
|
||||
}
|
||||
|
||||
private fun ensureOtherTeamDefault() {
|
||||
val options = _state.value.leagueTeamOptions
|
||||
if (_state.value.otherTeamName.isBlank() && options.isNotEmpty()) {
|
||||
_state.update { it.copy(otherTeamName = options.first()) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateMatchPlayers(clubId: Int, matchId: Int, ready: List<Int>, planned: List<Int>, played: List<Int>) {
|
||||
matchesApi.updateMatchPlayers(
|
||||
matchId,
|
||||
UpdateMatchPlayersBody(clubId = clubId, playersReady = ready, playersPlanned = planned, playersPlayed = played),
|
||||
)
|
||||
refresh(clubId)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user