feat(ClubSettings): add country and state code fields for regional calendar data
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:
Torsten Schulz (local)
2026-05-12 23:46:07 +02:00
parent 1e23171370
commit bea5facb7d
46 changed files with 4286 additions and 12 deletions

View File

@@ -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
---

View File

@@ -41,6 +41,7 @@ kotlin {
implementation(libs.koin.android)
implementation(libs.coil.compose)
implementation(libs.yalantis.ucrop)
implementation(libs.ktor.serialization.kotlinx.json)
}
}
}

View File

@@ -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)

View File

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

View File

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

View File

@@ -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))
}
}
}
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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) }
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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))
}
}
}

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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()
}
}

View File

@@ -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,
)

View File

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

View File

@@ -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,
}

View File

@@ -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(),
)

View File

@@ -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
}
}

View File

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

View File

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

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}