From 2f3f4fb275912438abee18dbbf7e1953caf7262d Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 14 May 2026 17:51:19 +0200 Subject: [PATCH] feat(Tournament): update tournament participation UI and localization - Changed the icon for tournament participations in the navigation to better represent the feature. - Updated localization keys for tournament participations across multiple languages, enhancing clarity and user understanding. - Introduced a new tab for official tournament participations in the mobile app, improving navigation and access to tournament details. - Enhanced the TournamentsScreen to include options for creating and managing official tournaments, streamlining user interactions. --- frontend/src/App.vue | 2 +- frontend/src/i18n/locales/de-CH.json | 2 +- frontend/src/i18n/locales/de-extended.json | 4 +- frontend/src/i18n/locales/de.json | 4 +- frontend/src/i18n/locales/en-AU.json | 2 +- frontend/src/i18n/locales/en-GB.json | 2 +- frontend/src/i18n/locales/en-US.json | 2 +- .../de/tt_tagebuch/app/AppDependencies.kt | 8 +- .../kotlin/de/tt_tagebuch/app/ui/AppRoot.kt | 17 +- .../ui/InternalTournamentEditorDetailTabs.kt | 716 ++++++++ .../app/ui/InternalTournamentEditorScreen.kt | 525 ++++++ .../ui/OfficialTournamentsWorkspaceLogic.kt | 656 +++++++ .../ui/OfficialTournamentsWorkspaceScreen.kt | 1615 +++++++++++++++++ .../app/ui/TeamManagementScreen.kt | 486 +++++ .../tt_tagebuch/app/ui/TournamentsScreen.kt | 324 +++- .../app/util/OfficialTournamentEligibility.kt | 160 ++ .../de/tt_tagebuch/shared/api/ClubTeamsApi.kt | 33 + .../shared/api/OfficialTournamentsApi.kt | 79 + .../de/tt_tagebuch/shared/api/SeasonsApi.kt | 18 + .../tt_tagebuch/shared/api/TournamentsApi.kt | 344 ++++ .../models/OfficialTournamentWorkspaceDtos.kt | 112 ++ .../tt_tagebuch/shared/api/models/Schedule.kt | 29 + .../shared/api/models/SeasonDto.kt | 9 + .../shared/api/models/TournamentEditorDtos.kt | 381 ++++ 24 files changed, 5472 insertions(+), 58 deletions(-) create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentEditorScreen.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/OfficialTournamentsWorkspaceLogic.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/OfficialTournamentsWorkspaceScreen.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamManagementScreen.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/util/OfficialTournamentEligibility.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/SeasonsApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/OfficialTournamentWorkspaceDtos.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/SeasonDto.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentEditorDtos.kt diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 586d1a09..c8553768 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -106,7 +106,7 @@ {{ $t('navigation.clubTournaments') }} - 📄 + 📋 {{ $t('navigation.tournamentParticipations') }} diff --git a/frontend/src/i18n/locales/de-CH.json b/frontend/src/i18n/locales/de-CH.json index 44472838..8b1a8e96 100644 --- a/frontend/src/i18n/locales/de-CH.json +++ b/frontend/src/i18n/locales/de-CH.json @@ -173,7 +173,7 @@ "statistics": "Trainings-Statistik", "tournaments": "Turnier", "clubTournaments": "Vereinsturniere", - "tournamentParticipations": "Turnierteilnahmen", + "tournamentParticipations": "Offizielle Turniere & Teilnahmen", "schedule": "Spielplän", "clubSettings": "Vereinsiistellige", "predefinedActivities": "Vordefinierte Aktivitäte", diff --git a/frontend/src/i18n/locales/de-extended.json b/frontend/src/i18n/locales/de-extended.json index 81de30b7..924d06bf 100644 --- a/frontend/src/i18n/locales/de-extended.json +++ b/frontend/src/i18n/locales/de-extended.json @@ -173,7 +173,7 @@ "statistics": "Trainings-Statistik", "tournaments": "Turniere", "clubTournaments": "Vereinsturniere", - "tournamentParticipations": "Turnierteilnahmen", + "tournamentParticipations": "Offizielle Turniere & Teilnahmen", "schedule": "Spielpläne", "clubSettings": "Vereinseinstellungen", "predefinedActivities": "Vordefinierte Aktivitäten", @@ -846,7 +846,7 @@ "miniChampionshipYear": "Jahr", "miniChampionshipYearHint": "12 = in diesem Jahr 12 oder 13 Jahre, 10 = 10 oder 11, 8 = 9 und jünger", "miniChampionshipLocation": "Ort", - "tournamentParticipations": "Turnierteilnahmen", + "tournamentParticipations": "Offizielle Turniere & Teilnahmen", "date": "Datum", "newTournament": "Neues Turnier", "unknownDate": "Unbekanntes Datum", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index bd7f7b15..381419ad 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -173,7 +173,7 @@ "statistics": "Trainings-Statistik", "tournaments": "Turniere", "clubTournaments": "Vereinsturniere", - "tournamentParticipations": "Turnierteilnahmen", + "tournamentParticipations": "Offizielle Turniere & Teilnahmen", "schedule": "Spielpläne", "clubSettings": "Vereinseinstellungen", "predefinedActivities": "Vordefinierte Aktivitäten", @@ -917,7 +917,7 @@ "miniChampionshipYear": "Jahr", "miniChampionshipYearHint": "12 = in diesem Jahr 12 oder 13 Jahre, 10 = 10 oder 11, 8 = 9 und jünger", "miniChampionshipLocation": "Ort", - "tournamentParticipations": "Turnierteilnahmen", + "tournamentParticipations": "Offizielle Turniere & Teilnahmen", "date": "Datum", "newTournament": "Neues Turnier", "unknownDate": "Unbekanntes Datum", diff --git a/frontend/src/i18n/locales/en-AU.json b/frontend/src/i18n/locales/en-AU.json index 38d5d657..7775ddb7 100644 --- a/frontend/src/i18n/locales/en-AU.json +++ b/frontend/src/i18n/locales/en-AU.json @@ -172,7 +172,7 @@ "statistics": "Training Statistics", "tournaments": "Tournaments", "clubTournaments": "Club Tournaments", - "tournamentParticipations": "Tournament Participations", + "tournamentParticipations": "Official tournaments & participations", "schedule": "Schedules", "clubSettings": "Club Settings", "predefinedActivities": "Predefined Activities", diff --git a/frontend/src/i18n/locales/en-GB.json b/frontend/src/i18n/locales/en-GB.json index 3ac08e6b..e95e64ed 100644 --- a/frontend/src/i18n/locales/en-GB.json +++ b/frontend/src/i18n/locales/en-GB.json @@ -172,7 +172,7 @@ "statistics": "Training Statistics", "tournaments": "Tournaments", "clubTournaments": "Club Tournaments", - "tournamentParticipations": "Tournament Participations", + "tournamentParticipations": "Official tournaments & participations", "schedule": "Schedules", "clubSettings": "Club Settings", "predefinedActivities": "Predefined Activities", diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 1b444151..1874f96b 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -172,7 +172,7 @@ "statistics": "Training Statistics", "tournaments": "Tournaments", "clubTournaments": "Club Tournaments", - "tournamentParticipations": "Tournament Participations", + "tournamentParticipations": "Official tournaments & participations", "schedule": "Schedules", "clubSettings": "Club Settings", "predefinedActivities": "Predefined Activities", diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt index 11dd9f69..8be6f4ba 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt @@ -28,6 +28,7 @@ import de.tt_tagebuch.shared.api.MembersApi import de.tt_tagebuch.shared.api.MyTischtennisApi import de.tt_tagebuch.shared.api.OfficialTournamentsApi import de.tt_tagebuch.shared.api.PermissionsApi +import de.tt_tagebuch.shared.api.SeasonsApi import de.tt_tagebuch.shared.api.SessionApi import de.tt_tagebuch.shared.api.TrainingGroupsApi import de.tt_tagebuch.shared.api.TrainingStatsApi @@ -103,8 +104,11 @@ class AppDependencies(context: Context) { val apiLogsManager = ApiLogsManager(ApiLogsApi(client)) val tournamentsApi = TournamentsApi(client) val matchesApi = MatchesApi(client) + val clubTeamsApi = ClubTeamsApi(client) + val seasonsApi = SeasonsApi(client) val clubInternalTournamentsManager = ClubInternalTournamentsManager(tournamentsApi) - val officialTournamentsReadManager = OfficialTournamentsReadManager(OfficialTournamentsApi(client)) + val officialTournamentsApi = OfficialTournamentsApi(client) + val officialTournamentsReadManager = OfficialTournamentsReadManager(officialTournamentsApi) val memberTransferConfigApi = MemberTransferConfigApi(client) val myTischtennisApi = MyTischtennisApi(client) val clickTtAccountApi = ClickTtAccountApi(client) @@ -133,7 +137,7 @@ class AppDependencies(context: Context) { ) val trainingStatsManager = TrainingStatsManager(trainingStatsApi) val scheduleManager = ScheduleManager( - ClubTeamsApi(client), + clubTeamsApi, matchesApi, ) val languageManager = LanguageManager(AndroidLanguageStorage(context.applicationContext)) diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt index 6623b443..f962f314 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt @@ -167,7 +167,7 @@ private fun visibleMainTabs(perms: UserClubPermissions?): List = MainTab.Calendar -> perms == null || perms.canReadDiary() || perms.canReadSchedule() || perms.canReadTournaments() - MainTab.Tournaments -> perms == null || perms.canReadTournaments() + MainTab.Tournaments, MainTab.OfficialParticipations -> perms == null || perms.canReadTournaments() MainTab.Stats -> perms == null || perms.canReadStatistics() } } @@ -186,6 +186,7 @@ private enum class MainTab { Calendar, Stats, Tournaments, + OfficialParticipations, Settings, } @@ -413,9 +414,13 @@ private fun MainTabContent( onOpenDiaryTab = { onNavigateTab(MainTab.Diary) }, onOpenSchedule = { onNavigateTab(MainTab.Schedule) }, onOpenTournaments = { onNavigateTab(MainTab.Tournaments) }, - onOpenOfficialParticipations = { onNavigateTab(MainTab.Tournaments) }, + onOpenOfficialParticipations = { onNavigateTab(MainTab.OfficialParticipations) }, ) MainTab.Tournaments -> TournamentsScreen(dependencies) + MainTab.OfficialParticipations -> OfficialTournamentsMenuRoute( + dependencies = dependencies, + onNavigateBack = { onNavigateTab(MainTab.Tournaments) }, + ) MainTab.Stats -> TrainingStatsScreen(dependencies) MainTab.Settings -> SettingsScreen( dependencies = dependencies, @@ -599,6 +604,12 @@ private fun MainNavigationRail( selected = selectedTab == MainTab.Tournaments, onClick = { onTabSelected(MainTab.Tournaments) }, ) + NavRailLeafItem( + emoji = "📋", + label = tr("navigation.tournamentParticipations", "Turnierteilnahmen"), + selected = selectedTab == MainTab.OfficialParticipations, + onClick = { onTabSelected(MainTab.OfficialParticipations) }, + ) } if (p.canReadSchedule()) { NavRailLeafItem( @@ -807,6 +818,7 @@ private fun mainTabEmoji(tab: MainTab): String = when (tab) { MainTab.Calendar -> "📆" MainTab.Stats -> "📊" MainTab.Tournaments -> "🏆" + MainTab.OfficialParticipations -> "📋" MainTab.Schedule -> "📅" MainTab.Settings -> "⚙️" } @@ -6259,6 +6271,7 @@ private fun tabTitle(tab: MainTab): String = when (tab) { MainTab.Schedule -> tr("navigation.schedule", "Terminplan") MainTab.Calendar -> tr("navigation.calendar", "Kalender") MainTab.Tournaments -> tr("navigation.clubTournaments", "Turniere") + MainTab.OfficialParticipations -> tr("navigation.tournamentParticipations", "Turnierteilnahmen") MainTab.Stats -> tr("navigation.statistics", "Statistik") MainTab.Settings -> tr("mobile.more", "Mehr") } diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt new file mode 100644 index 00000000..a394e9f9 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt @@ -0,0 +1,716 @@ +package de.tt_tagebuch.app.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.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.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.TournamentsApi +import de.tt_tagebuch.shared.api.models.AddTournamentClassBody +import de.tt_tagebuch.shared.api.models.CreateTournamentPairingBody +import de.tt_tagebuch.shared.api.models.TournamentAddInternalParticipantBody +import de.tt_tagebuch.shared.api.models.TournamentAddMatchResultBody +import de.tt_tagebuch.shared.api.models.TournamentClassDto +import de.tt_tagebuch.shared.api.models.TournamentDeleteKnockoutBody +import de.tt_tagebuch.shared.api.models.TournamentExternalParticipantRowDto +import de.tt_tagebuch.shared.api.models.TournamentFinishMatchBody +import de.tt_tagebuch.shared.api.models.TournamentMatchDto +import de.tt_tagebuch.shared.api.models.TournamentParticipantRowDto +import de.tt_tagebuch.shared.api.models.TournamentRemoveInternalParticipantBody +import de.tt_tagebuch.shared.api.models.UpdateParticipantClassBody +import de.tt_tagebuch.shared.api.models.UpdateTournamentClassBody +import de.tt_tagebuch.shared.api.models.AddExternalTournamentParticipantBody +import de.tt_tagebuch.shared.api.models.RemoveExternalTournamentParticipantBody +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +@Composable +internal fun TournamentEditorClassesTab( + clubId: Int, + tournamentId: Int, + classes: List, + tr: (String, String) -> String, + api: TournamentsApi, + scope: CoroutineScope, + onReload: () -> Unit, + onError: (String?) -> Unit, +) { + var showAdd by remember { mutableStateOf(false) } + var newName by remember { mutableStateOf("") } + var newDoubles by remember { mutableStateOf(false) } + if (showAdd) { + AlertDialog( + onDismissRequest = { showAdd = false }, + title = { Text(tr("tournaments.addClass", "Klasse anlegen")) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = newName, onValueChange = { newName = it }, label = { Text(tr("tournaments.className", "Klassenname")) }) + Row { + Text(tr("mobile.doublesTournament", "Doppel")) + androidx.compose.material.Switch(checked = newDoubles, onCheckedChange = { newDoubles = it }) + } + } + }, + confirmButton = { + TextButton( + onClick = { + if (newName.isBlank()) return@TextButton + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.addTournamentClass( + clubId, + tournamentId, + AddTournamentClassBody(name = newName.trim(), isDoubles = newDoubles), + ) + } + }.onFailure { onError(it.message) } + showAdd = false + newName = "" + newDoubles = false + onReload() + } + }, + ) { Text(tr("mobile.add", "Hinzufügen")) } + }, + dismissButton = { + TextButton(onClick = { showAdd = false }) { Text(tr("common.cancel", "Abbrechen")) } + }, + ) + } + Column(Modifier.fillMaxSize().padding(16.dp)) { + Button(onClick = { showAdd = true }, modifier = Modifier.fillMaxWidth()) { + Text(tr("tournaments.addClass", "Klasse anlegen")) + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) { + items(classes, key = { it.id }) { c -> + ClassRowEditor( + clubId = clubId, + tournamentId = tournamentId, + c = c, + tr = tr, + api = api, + scope = scope, + onReload = onReload, + onError = onError, + ) + } + } + } +} + +@Composable +private fun ClassRowEditor( + clubId: Int, + tournamentId: Int, + c: TournamentClassDto, + tr: (String, String) -> String, + api: TournamentsApi, + scope: CoroutineScope, + onReload: () -> Unit, + onError: (String?) -> Unit, +) { + var name by remember(c.id) { mutableStateOf(c.name) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + ) { + OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text(c.name) }) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp)) { + TextButton( + onClick = { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.updateTournamentClass( + clubId, + tournamentId, + c.id, + UpdateTournamentClassBody(name = name.ifBlank { null }), + ) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("mobile.save", "Speichern")) } + TextButton( + onClick = { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.deleteTournamentClass(clubId, tournamentId, c.id) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("mobile.delete", "Löschen")) } + } + Divider() + } +} + +@Composable +internal fun TournamentEditorParticipantsTab( + clubId: Int, + tournamentId: Int, + classes: List, + participants: List, + externalParticipants: List, + dependencies: AppDependencies, + tr: (String, String) -> String, + onReload: () -> Unit, + onError: (String?) -> Unit, +) { + val api = dependencies.tournamentsApi + val scope = dependencies.applicationScope + val membersState by dependencies.membersManager.state.collectAsState() + val scroll = rememberScrollState() + var addMenu by remember { mutableStateOf(false) } + var extDialog by remember { mutableStateOf(false) } + var extFirst by remember { mutableStateOf("") } + var extLast by remember { mutableStateOf("") } + + LaunchedEffect(clubId) { + dependencies.membersManager.loadMembers(clubId) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(tr("tournaments.internalParticipants", "Vereinsmitglieder"), fontWeight = FontWeight.SemiBold) + Row { + OutlinedButton(onClick = { addMenu = true }) { Text(tr("tournaments.addParticipant", "Teilnehmer hinzufügen")) } + DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }) { + val existing = participants.mapNotNull { it.clubMemberId }.toSet() + membersState.members + .filter { it.id !in existing } + .take(200) + .forEach { m -> + DropdownMenuItem( + onClick = { + addMenu = false + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.addInternalParticipant( + TournamentAddInternalParticipantBody( + clubId = clubId, + classId = null, + participant = m.id, + tournamentId = tournamentId, + ), + ) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { + Text("${m.firstName} ${m.lastName}".trim()) + } + } + } + } + participants.forEach { p -> + val label = p.member?.let { "${it.firstName.orEmpty()} ${it.lastName.orEmpty()}".trim() }.orEmpty().ifBlank { "#${p.id}" } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f)) { + Text(label, fontWeight = FontWeight.Medium) + ClassPickerChipRow( + classes = classes, + selectedClassId = p.classId, + tr = tr, + onPick = { classId -> + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.updateParticipantClass( + clubId, + tournamentId, + p.id, + UpdateParticipantClassBody(classId = classId, isExternal = false), + ) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) + } + TextButton( + onClick = { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.removeInternalParticipant( + TournamentRemoveInternalParticipantBody(clubId, tournamentId, p.id), + ) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("mobile.remove", "Entfernen")) } + } + Divider() + } + + Text(tr("tournaments.externalParticipants", "Externe Teilnehmer"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp)) + OutlinedButton(onClick = { extDialog = true }) { Text(tr("tournaments.addExternalParticipant", "Extern hinzufügen")) } + if (extDialog) { + AlertDialog( + onDismissRequest = { extDialog = false }, + title = { Text(tr("tournaments.addExternalParticipant", "Extern hinzufügen")) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(extFirst, { extFirst = it }, label = { Text(tr("members.firstName", "Vorname")) }) + OutlinedTextField(extLast, { extLast = it }, label = { Text(tr("members.lastName", "Nachname")) }) + } + }, + confirmButton = { + TextButton( + onClick = { + if (extFirst.isBlank() || extLast.isBlank()) return@TextButton + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.addExternalParticipant( + AddExternalTournamentParticipantBody( + clubId = clubId, + tournamentId = tournamentId, + classId = null, + firstName = extFirst.trim(), + lastName = extLast.trim(), + ), + ) + } + }.onFailure { onError(it.message) } + extDialog = false + extFirst = "" + extLast = "" + onReload() + } + }, + ) { Text(tr("mobile.add", "Hinzufügen")) } + }, + dismissButton = { TextButton(onClick = { extDialog = false }) { Text(tr("common.cancel", "Abbrechen")) } }, + ) + } + externalParticipants.forEach { e -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("${e.firstName.orEmpty()} ${e.lastName.orEmpty()} (${tr("tournaments.externalShort", "ext.")})".trim()) + TextButton( + onClick = { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.removeExternalParticipant( + RemoveExternalTournamentParticipantBody(clubId, tournamentId, e.id), + ) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("mobile.remove", "Entfernen")) } + } + Divider() + } + } +} + +@Composable +private fun ClassPickerChipRow( + classes: List, + selectedClassId: Int?, + tr: (String, String) -> String, + onPick: (Int?) -> Unit, +) { + var open by remember { mutableStateOf(false) } + val label = classes.find { it.id == selectedClassId }?.name ?: tr("tournaments.noClassSelected", "Keine Klasse") + TextButton(onClick = { open = true }) { Text("${tr("tournaments.classLabel", "Klasse")}: $label") } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + DropdownMenuItem(onClick = { open = false; onPick(null) }) { Text("—") } + classes.forEach { c -> + DropdownMenuItem(onClick = { open = false; onPick(c.id) }) { Text(c.name) } + } + } +} + +@Composable +internal fun TournamentEditorMatchesTab( + clubId: Int, + tournamentId: Int, + matches: List, + winningSets: Int, + tr: (String, String) -> String, + api: TournamentsApi, + scope: CoroutineScope, + onReload: () -> Unit, + onError: (String?) -> Unit, +) { + val scroll = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.deleteKnockoutMatches(TournamentDeleteKnockoutBody(clubId, tournamentId, classId = null)) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("tournaments.resetKnockoutShort", "KO löschen")) } + } + val grouped = matches.groupBy { it.round } + grouped.entries.sortedBy { it.key }.forEach { (round, list) -> + Text("$round (${list.size})", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.subtitle1) + list.forEach { m -> + MatchResultRow( + clubId = clubId, + tournamentId = tournamentId, + m = m, + winningSets = winningSets, + tr = tr, + api = api, + scope = scope, + onReload = onReload, + onError = onError, + ) + } + } + } +} + +@Composable +private fun MatchResultRow( + clubId: Int, + tournamentId: Int, + m: TournamentMatchDto, + @Suppress("UNUSED_PARAMETER") winningSets: Int, + tr: (String, String) -> String, + api: TournamentsApi, + scope: CoroutineScope, + onReload: () -> Unit, + onError: (String?) -> Unit, +) { + var resultInput by remember(m.id, m.tournamentResults) { mutableStateOf("") } + val p1 = displayNameFromPlayerJson(m.player1) + val p2 = displayNameFromPlayerJson(m.player2) + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text("$p1 vs $p2", style = MaterialTheme.typography.body2) + Text( + "${tr("tournaments.result", "Ergebnis")}: ${m.result ?: "—"} ${if (m.isFinished == true) "✓" else ""}", + style = MaterialTheme.typography.caption, + ) + if (m.isFinished != true) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp)) { + OutlinedTextField( + value = resultInput, + onValueChange = { resultInput = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("11:9") }, + singleLine = true, + ) + TextButton( + onClick = { + val r = normalizeResult(resultInput) ?: return@TextButton + val nextSet = (m.tournamentResults?.size ?: 0) + 1 + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.addMatchResult( + TournamentAddMatchResultBody( + clubId = clubId, + tournamentId = tournamentId, + matchId = m.id, + set = nextSet, + result = r, + ), + ) + } + }.onFailure { onError(it.message) } + resultInput = "" + onReload() + } + }, + ) { Text(tr("tournaments.addSetShort", "Satz")) } + TextButton( + onClick = { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.finishMatch( + TournamentFinishMatchBody(clubId, tournamentId, m.id), + ) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("tournaments.finishMatchShort", "Fertig")) } + } + } + Divider() + } +} + +private fun normalizeResult(s: String): String? { + val t = s.trim() + if (!t.contains(':')) return null + val parts = t.split(':') + if (parts.size != 2) return null + val a = parts[0].trim().toIntOrNull() ?: return null + val b = parts[1].trim().toIntOrNull() ?: return null + if (a < 0 || b < 0) return null + return "$a:$b" +} + +private fun displayNameFromPlayerJson(el: JsonElement?): String { + if (el == null || el is JsonNull) return "—" + val o = el as? JsonObject ?: return "?" + val member = o["member"] as? JsonObject + if (member != null) { + val fn = member["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty() + val ln = member["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty() + return "$fn $ln".trim().ifEmpty { "?" } + } + val fn = o["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty() + val ln = o["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty() + return "$fn $ln".trim().ifEmpty { "?" } +} + +@Composable +internal fun TournamentEditorPairingsTab( + clubId: Int, + tournamentId: Int, + classes: List, + participants: List, + externalParticipants: List, + tr: (String, String) -> String, + api: TournamentsApi, + scope: CoroutineScope, + onReload: () -> Unit, + onError: (String?) -> Unit, +) { + val doublesClasses = classes.filter { it.isDoubles == true } + var classId by remember(doublesClasses) { + mutableIntStateOf(doublesClasses.firstOrNull()?.id ?: 0) + } + var pairingJson by remember { mutableStateOf(null) } + var p1Menu by remember { mutableStateOf(false) } + var p2Menu by remember { mutableStateOf(false) } + var sel1 by remember { mutableStateOf?>(null) } + var sel2 by remember { mutableStateOf?>(null) } + + LaunchedEffect(clubId, tournamentId, classId) { + if (classId == 0) { + pairingJson = null + return@LaunchedEffect + } + pairingJson = runCatching { + withContext(Dispatchers.IO) { api.listPairings(clubId, tournamentId, classId) } + }.getOrElse { null } + } + + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { + if (doublesClasses.isEmpty()) { + Text(tr("tournaments.noDoublesClasses", "Keine Doppel-Klassen.")) + return@Column + } + Text(tr("tournaments.selectClass", "Klasse"), fontWeight = FontWeight.SemiBold) + doublesClasses.forEach { c -> + TextButton(onClick = { classId = c.id }) { + Text(if (classId == c.id) "✓ ${c.name}" else c.name) + } + } + Text(tr("tournaments.newPairing", "Neue Paarung"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { p1Menu = true }) { + Text(sel1?.let { "${it.first} #${it.second}" } ?: tr("tournaments.player1", "Spieler 1")) + } + DropdownMenu(expanded = p1Menu, onDismissRequest = { p1Menu = false }) { + participants.forEach { p -> + val label = p.member?.let { "${it.firstName.orEmpty()} ${it.lastName.orEmpty()}".trim() }.orEmpty().ifBlank { "ID ${p.id}" } + DropdownMenuItem( + onClick = { + p1Menu = false + sel1 = "member" to p.id + }, + ) { Text("$label (${tr("tournaments.internalShort", "int.")})") } + } + externalParticipants.forEach { ex -> + DropdownMenuItem( + onClick = { + p1Menu = false + sel1 = "external" to ex.id + }, + ) { Text("${ex.firstName.orEmpty()} ${ex.lastName.orEmpty()} (${tr("tournaments.externalShort", "ext.")})") } + } + } + OutlinedButton(onClick = { p2Menu = true }) { + Text(sel2?.let { "${it.first} #${it.second}" } ?: tr("tournaments.player2", "Spieler 2")) + } + DropdownMenu(expanded = p2Menu, onDismissRequest = { p2Menu = false }) { + participants.forEach { p -> + val label = p.member?.let { "${it.firstName.orEmpty()} ${it.lastName.orEmpty()}".trim() }.orEmpty().ifBlank { "ID ${p.id}" } + DropdownMenuItem( + onClick = { + p2Menu = false + sel2 = "member" to p.id + }, + ) { Text("$label (${tr("tournaments.internalShort", "int.")})") } + } + externalParticipants.forEach { ex -> + DropdownMenuItem( + onClick = { + p2Menu = false + sel2 = "external" to ex.id + }, + ) { Text("${ex.firstName.orEmpty()} ${ex.lastName.orEmpty()} (${tr("tournaments.externalShort", "ext.")})") } + } + } + } + Button( + onClick = { + val a = sel1 ?: return@Button + val b = sel2 ?: return@Button + if (a.second == b.second && a.first == b.first) return@Button + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.createPairing( + clubId, + tournamentId, + classId, + CreateTournamentPairingBody( + player1Type = a.first, + player1Id = a.second, + player2Type = b.first, + player2Id = b.second, + seeded = false, + ), + ) + } + }.onFailure { onError(it.message) } + sel1 = null + sel2 = null + onReload() + } + }, + modifier = Modifier.padding(top = 8.dp), + ) { Text(tr("mobile.add", "Hinzufügen")) } + + Divider(modifier = Modifier.padding(vertical = 12.dp)) + Text(tr("tournaments.pairings", "Paarungen"), fontWeight = FontWeight.SemiBold) + when (val pj = pairingJson) { + is JsonArray -> { + pj.forEach { elem -> + val id = (elem as? JsonObject)?.get("id")?.jsonPrimitive?.intOrNull ?: return@forEach + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(pairingSummary(elem), style = MaterialTheme.typography.body2) + TextButton( + onClick = { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.deletePairing(clubId, tournamentId, id) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("mobile.delete", "Löschen")) } + } + } + } + else -> Text("—", style = MaterialTheme.typography.caption) + } + } +} + +private fun pairingSummary(elem: JsonElement): String { + val o = elem as? JsonObject ?: return elem.toString() + val id = o["id"]?.jsonPrimitive?.intOrNull + val m1 = o["member1"] as? JsonObject + val m2 = o["member2"] as? JsonObject + val n1 = m1?.let { me -> + val mm = me["member"] as? JsonObject + if (mm != null) { + "${mm["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty()} ${mm["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty()}".trim() + } else { + "${me["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty()} ${me["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty()}".trim() + } + }.orEmpty().ifBlank { "?" } + val n2 = m2?.let { me -> + val mm = me["member"] as? JsonObject + if (mm != null) { + "${mm["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty()} ${mm["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty()}".trim() + } else { + "${me["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty()} ${me["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty()}".trim() + } + }.orEmpty().ifBlank { "?" } + return "#$id $n1 / $n2" +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentEditorScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentEditorScreen.kt new file mode 100644 index 00000000..ce1abb40 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentEditorScreen.kt @@ -0,0 +1,525 @@ +package de.tt_tagebuch.app.ui + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TopAppBar +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.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.models.InternalTournamentDetailDto +import de.tt_tagebuch.shared.api.models.SetTournamentModusBody +import de.tt_tagebuch.shared.api.models.TournamentClassDto +import de.tt_tagebuch.shared.api.models.TournamentClubTournamentBody +import de.tt_tagebuch.shared.api.models.TournamentCreateGroupMatchesBody +import de.tt_tagebuch.shared.api.models.TournamentCreateGroupsBody +import de.tt_tagebuch.shared.api.models.TournamentExternalParticipantRowDto +import de.tt_tagebuch.shared.api.models.TournamentGetExternalParticipantsBody +import de.tt_tagebuch.shared.api.models.TournamentGetParticipantsBody +import de.tt_tagebuch.shared.api.models.TournamentMatchDto +import de.tt_tagebuch.shared.api.models.TournamentParticipantRowDto +import de.tt_tagebuch.shared.api.models.UpdateTournamentMetaBody +import de.tt_tagebuch.shared.i18n.MobileStrings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement + +/** + * Vollständiger Turnier-Workspace (analog Web [TournamentTab]): Stammdaten, Ablauf/Gruppen, + * Klassen, Teilnehmer, Spiele, Doppel-Paarungen. Lädt und speichert über [de.tt_tagebuch.shared.api.TournamentsApi]. + */ +@Composable +internal fun InternalTournamentEditorScreen( + clubId: Int, + tournamentId: Int, + dependencies: AppDependencies, + onClose: () -> Unit, +) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val api = dependencies.tournamentsApi + val scope = dependencies.applicationScope + + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var detail by remember { mutableStateOf(null) } + var classes by remember { mutableStateOf>(emptyList()) } + var participants by remember { mutableStateOf>(emptyList()) } + var externalParticipants by remember { mutableStateOf>(emptyList()) } + var matches by remember { mutableStateOf>(emptyList()) } + var groupsJson by remember { mutableStateOf(null) } + + var tabIndex by remember { mutableIntStateOf(0) } + val showPairingsTab = detail?.isDoublesTournament == true + val tabTitles = remember(showPairingsTab, languageCode) { + buildList { + add(tr("mobile.tournamentEditorTabMeta", "Stammdaten")) + add(tr("mobile.tournamentEditorTabFlow", "Ablauf & Gruppen")) + add(tr("mobile.tournamentEditorTabClasses", "Klassen")) + add(tr("mobile.tournamentEditorTabParticipants", "Teilnehmer")) + add(tr("mobile.tournamentEditorTabMatches", "Spiele")) + if (showPairingsTab) add(tr("tournaments.pairings", "Doppel-Paarungen")) + } + } + + fun reloadAll() { + scope.launch { + loading = true + error = null + runCatching { + withContext(Dispatchers.IO) { + val d = api.getTournament(clubId, tournamentId) + val c = api.listTournamentClasses(clubId, tournamentId) + val p = api.listInternalParticipants( + TournamentGetParticipantsBody(clubId, tournamentId, classId = null), + ) + val e = api.listExternalParticipants( + TournamentGetExternalParticipantsBody(clubId, tournamentId, classId = null), + ) + val m = api.listMatches(clubId, tournamentId) + val g = api.getGroups(clubId, tournamentId) + TournamentEditorLoadResult(d, c, p, e, m, g) + } + }.fold( + onSuccess = { r -> + detail = r.detail + classes = r.classes + participants = r.participants + externalParticipants = r.external + matches = r.matches + groupsJson = r.groups + }, + onFailure = { t -> + error = t.message ?: tr("mobile.loadingError", "Fehler beim Laden") + }, + ) + loading = false + } + } + + LaunchedEffect(clubId, tournamentId) { + reloadAll() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + detail?.name ?: tr("mobile.tournamentEditorTitle", "Turnier bearbeiten"), + maxLines = 1, + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = tr("mobile.back", "Zurück")) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (error != null) { + Text(error!!, color = MaterialTheme.colors.error, modifier = Modifier.padding(16.dp)) + TextButton(onClick = { reloadAll() }) { + Text(tr("mobile.retry", "Erneut versuchen")) + } + } + if (loading && detail == null) { + CircularProgressIndicator(modifier = Modifier.padding(24.dp).align(Alignment.CenterHorizontally)) + return@Column + } + val d = detail ?: return@Column + + TabRow( + selectedTabIndex = tabIndex.coerceAtMost(tabTitles.lastIndex), + modifier = Modifier.fillMaxWidth(), + ) { + tabTitles.forEachIndexed { i, title -> + Tab( + selected = tabIndex == i, + onClick = { tabIndex = i }, + text = { Text(title, maxLines = 1) }, + ) + } + } + Divider() + + when (tabIndex) { + 0 -> TournamentEditorMetaTab( + detail = d, + tr = ::tr, + onSave = { body -> + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + api.updateTournament(clubId, tournamentId, body) + } + }.fold( + onSuccess = { detail = it }, + onFailure = { error = it.message }, + ) + reloadAll() + } + }, + ) + 1 -> TournamentEditorFlowTab( + clubId = clubId, + tournamentId = tournamentId, + detail = d, + groupsJson = groupsJson, + tr = ::tr, + api = api, + scope = scope, + onReload = { reloadAll() }, + onError = { error = it }, + ) + 2 -> TournamentEditorClassesTab( + clubId = clubId, + tournamentId = tournamentId, + classes = classes, + tr = ::tr, + api = api, + scope = scope, + onReload = { reloadAll() }, + onError = { error = it }, + ) + 3 -> TournamentEditorParticipantsTab( + clubId = clubId, + tournamentId = tournamentId, + classes = classes, + participants = participants, + externalParticipants = externalParticipants, + dependencies = dependencies, + tr = ::tr, + onReload = { reloadAll() }, + onError = { error = it }, + ) + 4 -> TournamentEditorMatchesTab( + clubId = clubId, + tournamentId = tournamentId, + matches = matches, + winningSets = d.winningSets ?: 3, + tr = ::tr, + api = api, + scope = scope, + onReload = { reloadAll() }, + onError = { error = it }, + ) + 5 -> if (showPairingsTab) { + TournamentEditorPairingsTab( + clubId = clubId, + tournamentId = tournamentId, + classes = classes, + participants = participants, + externalParticipants = externalParticipants, + tr = ::tr, + api = api, + scope = scope, + onReload = { reloadAll() }, + onError = { error = it }, + ) + } else { + Spacer(Modifier.height(16.dp)) + } + } + } + } +} + +private data class TournamentEditorLoadResult( + val detail: InternalTournamentDetailDto, + val classes: List, + val participants: List, + val external: List, + val matches: List, + val groups: JsonElement, +) + +@Composable +private fun TournamentEditorMetaTab( + detail: InternalTournamentDetailDto, + tr: (String, String) -> String, + onSave: (UpdateTournamentMetaBody) -> Unit, +) { + var name by remember(detail.id) { mutableStateOf(detail.name.orEmpty()) } + var date by remember(detail.id) { mutableStateOf(detail.date.orEmpty()) } + var winningSets by remember(detail.id) { mutableStateOf((detail.winningSets ?: 3).toString()) } + var tables by remember(detail.id) { mutableStateOf(detail.numberOfTables?.toString().orEmpty()) } + var doubles by remember(detail.id) { mutableStateOf(detail.isDoublesTournament == true) } + val scroll = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(tr("tournaments.tournamentName", "Turniername"), fontWeight = FontWeight.SemiBold) + OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField( + value = date, + onValueChange = { date = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(tr("tournaments.date", "Datum")) }, + singleLine = true, + ) + OutlinedTextField( + value = winningSets, + onValueChange = { winningSets = it.filter { ch -> ch.isDigit() }.take(2) }, + modifier = Modifier.fillMaxWidth(), + label = { Text(tr("tournaments.winningSets", "Gewinnsätze")) }, + singleLine = true, + ) + OutlinedTextField( + value = tables, + onValueChange = { tables = it.filter { ch -> ch.isDigit() } }, + modifier = Modifier.fillMaxWidth(), + label = { Text(tr("mobile.tables", "Tische")) }, + singleLine = true, + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Text(tr("mobile.doublesTournament", "Doppel-Turnier")) + Switch(checked = doubles, onCheckedChange = { doubles = it }) + } + Button( + onClick = { + val ws = winningSets.toIntOrNull()?.coerceAtLeast(1) ?: 3 + val nt = tables.toIntOrNull() + onSave( + UpdateTournamentMetaBody( + name = name.ifBlank { null }, + date = date.ifBlank { null }, + winningSets = ws, + numberOfTables = nt, + isDoublesTournament = doubles, + ), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(tr("mobile.save", "Speichern")) + } + } +} + +@Composable +private fun TournamentEditorFlowTab( + clubId: Int, + tournamentId: Int, + detail: InternalTournamentDetailDto, + groupsJson: JsonElement?, + tr: (String, String) -> String, + api: de.tt_tagebuch.shared.api.TournamentsApi, + scope: kotlinx.coroutines.CoroutineScope, + onReload: () -> Unit, + onError: (String?) -> Unit, +) { + val isGroups = detail.type == "groups" + var isGroupMode by remember(detail.id, detail.type) { mutableStateOf(isGroups) } + var numGroups by remember(detail.id) { mutableStateOf((detail.numberOfGroups?.takeIf { it > 0 } ?: 1).toString()) } + var adv by remember(detail.id) { mutableStateOf((detail.advancingPerGroup ?: 1).toString()) } + var createGroupsInput by remember { mutableStateOf(numGroups) } + val scroll = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(tr("tournaments.tournamentMode", "Turniermodus"), fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(tr("tournaments.groupPhase", "Gruppenphase")) + Switch( + checked = isGroupMode, + onCheckedChange = { isGroupMode = it }, + ) + } + OutlinedTextField( + value = numGroups, + onValueChange = { numGroups = it.filter { ch -> ch.isDigit() }.take(2) }, + label = { Text(tr("mobile.groups", "Gruppen")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = adv, + onValueChange = { adv = it.filter { ch -> ch.isDigit() }.take(2) }, + label = { Text(tr("tournaments.advancingPerGroup", "Weiterkommende pro Gruppe")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Button( + onClick = { + val n = numGroups.toIntOrNull()?.coerceAtLeast(1) ?: 1 + val a = adv.toIntOrNull()?.coerceAtLeast(1) ?: 1 + scope.launch { + runCatching { + kotlinx.coroutines.withContext(Dispatchers.IO) { + api.setModus( + SetTournamentModusBody( + clubId = clubId, + tournamentId = tournamentId, + type = if (isGroupMode) "groups" else "knockout", + numberOfGroups = n, + advancingPerGroup = a, + ), + ) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(tr("mobile.apply", "Übernehmen")) + } + Divider(modifier = Modifier.padding(vertical = 8.dp)) + Text(tr("mobile.tournamentGroupActions", "Gruppen-Aktionen"), fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = createGroupsInput, + onValueChange = { createGroupsInput = it.filter { ch -> ch.isDigit() }.take(2) }, + label = { Text(tr("tournaments.numberOfGroupsShort", "Anzahl Gruppen")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { + val n = createGroupsInput.toIntOrNull() + scope.launch { + runCatching { + kotlinx.coroutines.withContext(Dispatchers.IO) { + api.createEmptyGroups( + TournamentCreateGroupsBody(clubId, tournamentId, n), + ) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("tournaments.createEmptyGroups", "Leere Gruppen")) } + TextButton( + onClick = { + scope.launch { + runCatching { + kotlinx.coroutines.withContext(Dispatchers.IO) { + api.fillGroups(TournamentClubTournamentBody(clubId, tournamentId)) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("tournaments.fillGroups", "Aufteilen")) } + } + TextButton( + onClick = { + scope.launch { + runCatching { + kotlinx.coroutines.withContext(Dispatchers.IO) { + api.createGroupMatches( + TournamentCreateGroupMatchesBody(clubId, tournamentId, classId = null), + ) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("tournaments.createGroupMatches", "Gruppenspiele erstellen")) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { + scope.launch { + runCatching { + kotlinx.coroutines.withContext(Dispatchers.IO) { + api.resetGroups(TournamentClubTournamentBody(clubId, tournamentId)) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("tournaments.resetGroups", "Gruppen zurücksetzen")) } + TextButton( + onClick = { + scope.launch { + runCatching { + kotlinx.coroutines.withContext(Dispatchers.IO) { + api.resetMatches(TournamentClubTournamentBody(clubId, tournamentId)) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + ) { Text(tr("tournaments.resetMatches", "Spiele zurücksetzen")) } + } + Divider(modifier = Modifier.padding(vertical = 8.dp)) + Text(tr("mobile.tournamentKnockout", "K.-o.-Runde"), fontWeight = FontWeight.SemiBold) + Button( + onClick = { + scope.launch { + runCatching { + kotlinx.coroutines.withContext(Dispatchers.IO) { + api.startKnockout( + de.tt_tagebuch.shared.api.models.TournamentStartKnockoutBody(clubId, tournamentId), + ) + } + }.onFailure { onError(it.message) } + onReload() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(tr("tournaments.startKnockout", "K.-o. starten")) + } + Text( + tr("mobile.tournamentGroupsPreview", "Gruppen-Daten (Überblick)") + ": " + + when (groupsJson) { + is kotlinx.serialization.json.JsonArray -> "${groupsJson.size} " + tr("mobile.entries", "Einträge") + null -> "—" + else -> tr("mobile.dataLoaded", "geladen") + }, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f), + ) + } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/OfficialTournamentsWorkspaceLogic.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/OfficialTournamentsWorkspaceLogic.kt new file mode 100644 index 00000000..98002465 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/OfficialTournamentsWorkspaceLogic.kt @@ -0,0 +1,656 @@ +package de.tt_tagebuch.app.ui + +import de.tt_tagebuch.app.util.OfficialTournamentEligibility +import de.tt_tagebuch.shared.api.models.Member +import de.tt_tagebuch.shared.api.models.OfficialCompetitionMemberStateDto +import de.tt_tagebuch.shared.api.models.OfficialParsedCompetitionDto +import de.tt_tagebuch.shared.api.models.OfficialParsedDataDto +import de.tt_tagebuch.shared.api.models.OfficialParsedTournamentEnvelopeDto +import de.tt_tagebuch.shared.api.models.OfficialParticipationBucketDto +import de.tt_tagebuch.shared.api.models.OfficialParticipationEntryDto +import de.tt_tagebuch.shared.api.models.OfficialTournamentListRowDto +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import java.text.Collator +import java.util.Calendar +import java.util.Locale + +internal enum class OfficialParticipationRange(val vueKey: String) { + M3("3m"), + M6("6m"), + M12("12m"), + Y2("2y"), + PREV("prev"), + ALL("all"), +} + +internal data class ParticipationState( + val wants: Boolean, + val registered: Boolean, + val participated: Boolean, + val placement: String?, +) + +internal data class ClubHistoryRow( + val key: String, + val memberName: String?, + val tournamentName: String?, + val competitionName: String?, + val date: String?, + val placement: String?, +) + +internal data class ParticipantTableRow( + val key: String, + val memberId: Int, + val memberName: String, + val competitionName: String, + val start: String, + val wants: Boolean, + val registered: Boolean, + val participated: Boolean, + val placement: String?, +) + +internal data class ParticipantGroup( + val memberName: String, + val memberId: Int, + val items: List, +) + +internal data class ActiveCompetitionMemberRow( + val key: String, + val memberName: String, + val wants: Boolean, + val registered: Boolean, + val participated: Boolean, + val placement: String?, + val statusClass: String, + val statusText: String, +) + +internal data class CompetitionSummaryCounts( + val interested: Int, + val registered: Int, + val participated: Int, +) + +internal data class TournamentListStatus( + val label: String, + val className: String, +) + +internal data class RegistrationDeadlineInfo( + val label: String, + val statusClass: String, +) + +internal data class WorkflowNotice( + val label: String, + val statusClass: String, +) + +internal data class CompetitionForMemberRow( + val key: String, + val name: String, + val date: String, + val time: String, + val entryFee: String, + val raw: OfficialParsedCompetitionDto, +) + +private val collator: Collator = Collator.getInstance(Locale.GERMAN).apply { strength = Collator.PRIMARY } + +internal fun participationKey(competitionId: Int, memberId: Int): String = "$competitionId-$memberId" + +internal fun buildParticipationMapFromApi(entries: List): Map { + val out = LinkedHashMap() + for (e in entries) { + val key = participationKey(e.competitionId, e.memberId) + out[key] = ParticipationState( + wants = e.wants, + registered = e.registered, + participated = e.participated, + placement = e.placement, + ) + } + return out +} + +internal fun getParticipation( + map: Map, + competitionId: Any, + memberId: Any, +): ParticipationState { + val key = "${competitionId}-${memberId}" + return map[key] ?: ParticipationState(false, false, false, null) +} + +internal fun tournamentListRawDate(t: OfficialTournamentListRowDto): String = + (t.eventDate ?: "").trim() + +internal fun tournamentDateRange(t: OfficialTournamentListRowDto): Pair { + val raw = tournamentListRawDate(t) + if (raw.isEmpty()) return Pair(null, null) + val matches = Regex("(\\d{1,2}\\.\\d{1,2}\\.\\d{4}|\\d{4}-\\d{2}-\\d{2})").findAll(raw).mapNotNull { m -> + OfficialTournamentEligibility.parseDateFlexible(m.value) + }.sortedBy { it.timeInMillis }.toList() + if (matches.isEmpty()) return Pair(null, null) + return Pair(matches.first(), matches.last()) +} + +internal fun tournamentListStatus(selectedId: Int?, t: OfficialTournamentListRowDto): TournamentListStatus { + if (selectedId != null && selectedId == t.id) { + return TournamentListStatus("Aktiv", "is-active") + } + val range = tournamentDateRange(t) + val date = range.second ?: range.first + if (date == null) return TournamentListStatus("Ohne Datum", "is-neutral") + val today = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + return if (date.timeInMillis < today.timeInMillis) { + TournamentListStatus("Vergangen", "is-past") + } else { + TournamentListStatus("Kommend", "is-upcoming") + } +} + +internal fun filterTournamentList( + list: List, + tournamentSearch: String, + showOlderTournaments: Boolean, + selectedTournamentId: Int?, +): List { + val search = tournamentSearch.trim().lowercase(Locale.ROOT) + val searchDate = if (search.isNotEmpty()) OfficialTournamentEligibility.parseDateFlexible(search) else null + val today = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + val cutoff = Calendar.getInstance().apply { + timeInMillis = today.timeInMillis + add(Calendar.DAY_OF_MONTH, -14) + } + + val base = list.filter { tournament -> + val isSelected = selectedTournamentId != null && selectedTournamentId == tournament.id + val range = tournamentDateRange(tournament) + val end = range.second ?: range.first + if (!showOlderTournaments && end != null && end.timeInMillis < cutoff.timeInMillis && !isSelected) { + return@filter false + } + if (search.isEmpty()) return@filter true + if (searchDate != null) { + val start = range.first ?: return@filter false + val endDate = range.second ?: range.first + return@filter !(searchDate.timeInMillis < start.timeInMillis || searchDate.timeInMillis > endDate!!.timeInMillis) + } + val haystack = listOfNotNull(tournament.title, tournament.eventDate).joinToString(" ").lowercase(Locale.ROOT) + return@filter haystack.contains(search) + } + + return base.sortedWith { a, b -> + val ra = tournamentDateRange(a) + val rb = tournamentDateRange(b) + val ta = (ra.second ?: ra.first)?.timeInMillis ?: 0L + val tb = (rb.second ?: rb.first)?.timeInMillis ?: 0L + compareValues(tb, ta) + } +} + +internal fun jsonElementToStringList(el: JsonElement?): List { + if (el == null || el is JsonNull) return emptyList() + if (el is JsonArray) { + return el.mapNotNull { item -> + when (item) { + is JsonPrimitive -> item.contentOrNull + else -> item.toString() + } + } + } + if (el is JsonPrimitive && el.isString) return listOf(el.content) + return emptyList() +} + +internal fun tournamentLocationLabel(parsed: OfficialParsedDataDto?): String { + val places = jsonElementToStringList(parsed?.austragungsorte) + val line = places.firstOrNull { Regex("^PLZ\\s*/\\s*Ort:", RegexOption.IGNORE_CASE).containsMatchIn(it) } + ?: return "" + return line.replaceFirst(Regex("^PLZ\\s*/\\s*Ort:", RegexOption.IGNORE_CASE), "").trim() +} + +internal fun parseDateTimeFlexible(s: String?): Calendar? { + if (s.isNullOrBlank()) return null + val t = s.trim() + val m = Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})(?:\\s+(\\d{1,2}):(\\d{2}))?$").find(t) + if (m != null) { + val day = m.groupValues[1].toInt() + val month = m.groupValues[2].toInt() + val year = m.groupValues[3].toInt() + val hh = m.groupValues[4].ifEmpty { "0" }.toInt() + val mm = m.groupValues[5].ifEmpty { "0" }.toInt() + return Calendar.getInstance().apply { + set(year, month - 1, day, hh, mm, 0) + set(Calendar.MILLISECOND, 0) + } + } + return OfficialTournamentEligibility.parseDateFlexible(t) +} + +internal fun registrationDeadlineAt(parsed: OfficialParsedDataDto?): Calendar? { + if (parsed == null) return null + val deadlines = mutableListOf() + deadlines.addAll(jsonElementToStringList(parsed.meldeschluesse)) + for (c in parsed.competitions) { + listOfNotNull( + c.registrationDeadlineDate, + c.registrationDeadlineOnline, + c.meldeschlussDatum, + c.meldeschlussOnline, + ).forEach { if (!it.isNullOrBlank()) deadlines.add(it) } + } + var earliest: Calendar? = null + for (entry in deadlines) { + val p = parseDateTimeFlexible(entry) ?: continue + if (earliest == null || p.timeInMillis < earliest.timeInMillis) earliest = p + } + return earliest +} + +internal fun registrationDeadlineInfo(parsed: OfficialParsedDataDto?): RegistrationDeadlineInfo { + val at = registrationDeadlineAt(parsed) + if (at == null) { + return RegistrationDeadlineInfo("Meldeschluss unbekannt", "is-neutral") + } + var earliestText = "" + val deadlines = mutableListOf() + parsed?.let { p -> + deadlines.addAll(jsonElementToStringList(p.meldeschluesse)) + for (c in p.competitions) { + listOfNotNull( + c.registrationDeadlineDate, + c.registrationDeadlineOnline, + c.meldeschlussDatum, + c.meldeschlussOnline, + ).forEach { if (!it.isNullOrBlank()) deadlines.add(it) } + } + } + for (entry in deadlines) { + val p = parseDateTimeFlexible(entry) ?: continue + if (p.timeInMillis == at.timeInMillis) { + earliestText = entry + break + } + } + val now = System.currentTimeMillis() + return if (at.timeInMillis < now) { + RegistrationDeadlineInfo("Meldeschluss abgelaufen: $earliestText", "is-danger") + } else { + RegistrationDeadlineInfo("Meldeschluss offen: $earliestText", "is-success") + } +} + +internal fun registrationSummary(map: Map): Pair { + val registered = map.values.count { it.registered && !it.participated } + val participated = map.values.count { it.participated } + return Pair(registered, participated) +} + +internal fun pendingAutoRegistrationCount(map: Map): Int = + map.values.count { it.wants && !it.registered && !it.participated } + +internal fun canAutoRegister(selectedId: Int?, map: Map, parsed: OfficialParsedDataDto?): Boolean { + if (selectedId == null || pendingAutoRegistrationCount(map) == 0) return false + val at = registrationDeadlineAt(parsed) ?: return true + return at.timeInMillis >= System.currentTimeMillis() +} + +internal fun workflowNotices(map: Map): List { + val notices = mutableListOf() + val pending = pendingAutoRegistrationCount(map) + val (reg, _) = registrationSummary(map) + if (pending > 0) { + notices.add(WorkflowNotice("$pending zur Anmeldung bereit", "is-warning")) + } + if (reg > 0) { + notices.add(WorkflowNotice("$reg Ergebnisse offen", "is-info")) + } + if (pending == 0 && reg == 0) { + notices.add(WorkflowNotice("Keine offenen Aufgaben", "is-neutral")) + } + return notices +} + +internal fun primaryHeaderAction(map: Map): String { + if (pendingAutoRegistrationCount(map) > 0) return "auto-register" + val (reg, _) = registrationSummary(map) + if (reg > 0) return "results" + return "members" +} + +internal fun competitionStatusSummary( + c: OfficialParsedCompetitionDto, + parsed: OfficialParsedTournamentEnvelopeDto?, + members: List, + map: Map, +): CompetitionSummaryCounts { + var interested = 0 + var registered = 0 + var participated = 0 + for (member in OfficialTournamentEligibility.eligibleActiveMembers(parsed, c, members)) { + val p = getParticipation(map, c.id, member.id) + when { + p.participated -> participated++ + p.registered -> registered++ + p.wants -> interested++ + } + } + return CompetitionSummaryCounts(interested, registered, participated) +} + +internal fun activeCompetitionMemberRows( + c: OfficialParsedCompetitionDto, + parsed: OfficialParsedTournamentEnvelopeDto?, + members: List, + map: Map, + memberNameById: (Int) -> String, +): List { + val rows = mutableListOf() + for (member in OfficialTournamentEligibility.eligibleActiveMembers(parsed, c, members)) { + val p = getParticipation(map, c.id, member.id) + if (!p.wants && !p.registered && !p.participated) continue + val (statusClass, statusText) = when { + p.participated -> "status-played" to "Hat gespielt" + p.registered -> "status-registered" to "Angemeldet" + p.wants -> "status-wants" to "Möchte teilnehmen" + else -> "status-none" to "Nicht interessiert" + } + rows.add( + ActiveCompetitionMemberRow( + key = participationKey(c.id, member.id), + memberName = memberNameById(member.id), + wants = p.wants, + registered = p.registered, + participated = p.participated, + placement = p.placement, + statusClass = statusClass, + statusText = statusText, + ), + ) + } + rows.sortWith { a, b -> collator.compare(a.memberName, b.memberName) } + return rows +} + +internal fun participantsRows( + parsed: OfficialParsedTournamentEnvelopeDto?, + map: Map, + participantsFilter: String, + memberNameById: (Int) -> String, +): List { + if (parsed?.parsedData == null) return emptyList() + val comps = parsed.parsedData!!.competitions + val compById = comps.associateBy { it.id.toString() } + val seen = mutableSetOf() + val merged = mutableListOf>() + for (e in parsed.participation) { + val cid = e.competitionId.toString() + val mid = e.memberId.toString() + val key = "$cid-$mid" + if (seen.add(key)) merged.add(cid to mid) + } + for (k in map.keys) { + if (seen.contains(k)) continue + val midPart = k.substringAfterLast('-') + val cidPart = k.substringBeforeLast('-') + if (cidPart.isEmpty() || midPart.isEmpty()) continue + merged.add(cidPart to midPart) + } + val rows = mutableListOf() + for ((competitionId, memberId) in merged) { + val c = compById[competitionId] ?: continue + val current = getParticipation(map, competitionId, memberId) + val midInt = memberId.toIntOrNull() ?: continue + val cidInt = competitionId.toIntOrNull() ?: continue + val start = (c.startTime ?: c.startzeit ?: "–").toString() + val base = ParticipantTableRow( + key = "$competitionId-$memberId", + memberId = midInt, + memberName = memberNameById(midInt), + competitionName = (c.ageClassCompetition ?: c.altersklasseWettbewerb ?: "").toString(), + start = start, + registered = current.registered, + participated = current.participated, + wants = current.wants, + placement = current.placement, + ) + when (participantsFilter) { + "all" -> rows.add(base) + "open" -> if (!base.registered && !base.participated) rows.add(base) + "registered" -> if (base.registered && !base.participated) rows.add(base) + "participated" -> if (base.participated) rows.add(base) + } + } + rows.sortWith { a, b -> + val c1 = collator.compare(a.memberName, b.memberName) + if (c1 != 0) c1 else collator.compare(a.competitionName, b.competitionName) + } + return rows +} + +internal fun participantsGroups(rows: List): List { + val byMember = LinkedHashMap>() + for (row in rows) { + byMember.getOrPut(row.memberName) { mutableListOf() }.add(row) + } + val groups = mutableListOf() + for ((memberName, items) in byMember) { + items.sortWith { a, b -> collator.compare(a.competitionName, b.competitionName) } + val mid = items.firstOrNull()?.memberId ?: 0 + groups.add(ParticipantGroup(memberName = memberName, memberId = mid, items = items.toList())) + } + groups.sortWith { a, b -> collator.compare(a.memberName, b.memberName) } + return groups +} + +internal fun resultsRows( + parsed: OfficialParsedTournamentEnvelopeDto?, + map: Map, + memberNameById: (Int) -> String, +): List { + if (parsed?.parsedData == null) return emptyList() + val comps = parsed.parsedData!!.competitions + val compById = comps.associateBy { it.id.toString() } + val rows = mutableListOf() + for ((key, p) in map) { + if (!p.participated) continue + val parts = key.split("-") + if (parts.size < 2) continue + val competitionId = parts[0] + val memberId = parts[1] + val c = compById[competitionId] ?: continue + val midInt = memberId.toIntOrNull() ?: continue + val cidInt = competitionId.toIntOrNull() ?: continue + rows.add( + ParticipantTableRow( + key = key, + memberId = midInt, + memberName = memberNameById(midInt), + competitionName = (c.ageClassCompetition ?: c.altersklasseWettbewerb ?: "").toString(), + start = (c.startTime ?: c.startzeit ?: "–").toString(), + wants = p.wants, + registered = p.registered, + participated = p.participated, + placement = p.placement, + ), + ) + } + rows.sortWith { a, b -> + val c1 = collator.compare(a.memberName, b.memberName) + if (c1 != 0) c1 else collator.compare(a.competitionName, b.competitionName) + } + return rows +} + +internal fun resultsGroups(rows: List): List = + participantsGroups(rows) + +internal fun splitDateTime(str: String): Pair { + val s = str.trim() + val dm = Regex("(\\d{1,2}\\.\\d{1,2}\\.\\d{4})").find(s) + val tm = Regex("(\\d{1,2}:\\d{2})").find(s) + return (dm?.value ?: "–") to (tm?.value ?: "–") +} + +internal fun entryFeeForCompetition(parsed: OfficialParsedDataDto?, c: OfficialParsedCompetitionDto): String { + val title = (c.ageClassCompetition ?: c.altersklasseWettbewerb ?: "").trim() + val ageClassMatch = Regex("(U\\d+|AK\\s*\\d+)", RegexOption.IGNORE_CASE).find(title) ?: return entryFeeFromStartgeld(c) + val ageClass = ageClassMatch.value.uppercase(Locale.ROOT).replace("\\s+".toRegex(), "") + val feesEl = parsed?.entryFees + if (feesEl is JsonObject) { + val feeObj = feesEl[ageClass] as? JsonObject ?: feesEl.entries.firstOrNull { it.key.uppercase(Locale.ROOT).replace(" ", "") == ageClass }?.value as? JsonObject + if (feeObj != null) { + val amount = feeObj["amount"]?.toString()?.trim('"') ?: return entryFeeFromStartgeld(c) + val currency = feeObj["currency"]?.toString()?.trim('"') ?: "" + return "$amount$currency" + } + } + return entryFeeFromStartgeld(c) +} + +private fun entryFeeFromStartgeld(c: OfficialParsedCompetitionDto): String { + val sg = c.startgeld ?: c.entryFee ?: return "–" + val m = Regex("(\\d+(?:[,.]\\d+)?)\\s*(?:€|Euro|EUR)?").find(sg) + return if (m != null) "${m.groupValues[1]}€" else "–" +} + +internal fun competitionsForMember( + parsed: OfficialParsedTournamentEnvelopeDto?, + member: Member, +): List { + val comps = parsed?.parsedData?.competitions ?: return emptyList() + val rows = mutableListOf() + comps.forEachIndexed { idx, c -> + if (!OfficialTournamentEligibility.isEligibleForCompetition(member, c, parsed)) return@forEachIndexed + val title = (c.ageClassCompetition ?: c.altersklasseWettbewerb ?: "").trim() + val st = splitDateTime(c.startTime ?: c.startzeit ?: "") + val fee = entryFeeForCompetition(parsed.parsedData, c) + rows.add( + CompetitionForMemberRow( + key = idx.toString(), + name = title, + date = st.first, + time = st.second, + entryFee = fee, + raw = c, + ), + ) + } + return rows +} + +internal fun computeClubHistoryRows( + buckets: List, + range: OfficialParticipationRange, +): List { + val now = java.time.LocalDate.now(java.time.ZoneId.systemDefault()) + val (from, to) = participationRangeBoundsLocal(now, range) + val rows = mutableListOf() + for (t in buckets) { + for (e in t.entries) { + if (!participationEntryInRange(e, t, from, to)) continue + rows.add( + ClubHistoryRow( + key = "club-${t.tournamentId}-${e.competitionId}-${e.memberId}", + memberName = e.memberName, + tournamentName = t.title, + competitionName = e.competitionName, + date = e.date ?: t.startDate, + placement = e.placement, + ), + ) + } + } + rows.sortWith { a, b -> + val c1 = collator.compare(a.memberName ?: "", b.memberName ?: "") + if (c1 != 0) c1 else collator.compare(a.competitionName ?: "", b.competitionName ?: "") + } + return rows +} + +private fun participationRangeBoundsLocal( + now: java.time.LocalDate, + range: OfficialParticipationRange, +): Pair { + return when (range) { + OfficialParticipationRange.ALL -> Pair(null, null) + OfficialParticipationRange.M3 -> Pair(now.minusMonths(3), null) + OfficialParticipationRange.M6 -> Pair(now.minusMonths(6), null) + OfficialParticipationRange.M12 -> Pair(now.minusMonths(12), null) + OfficialParticipationRange.Y2 -> Pair(now.minusYears(2), null) + OfficialParticipationRange.PREV -> { + val y = if (now.monthValue >= 7) now.year else now.year - 1 + Pair(java.time.LocalDate.of(y - 1, 7, 1), java.time.LocalDate.of(y, 6, 30)) + } + } +} + +private fun participationEntryInRange( + e: OfficialParticipationEntryDto, + b: OfficialParticipationBucketDto, + from: java.time.LocalDate?, + to: java.time.LocalDate?, +): Boolean { + if (from == null && to == null) return true + val d = dateFromDmy(e.date) ?: dateFromDmy(b.startDate) ?: return true + if (from != null && d.isBefore(from)) return false + if (to != null && d.isAfter(to)) return false + return true +} + +private fun dateFromDmy(s: String?): java.time.LocalDate? { + if (s.isNullOrBlank()) return null + val cal = OfficialTournamentEligibility.parseDateFlexible(s) ?: return null + return java.time.LocalDate.of( + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH) + 1, + cal.get(Calendar.DAY_OF_MONTH), + ) +} + +internal fun historySummaryText( + loading: Boolean, + rows: List, + range: OfficialParticipationRange, +): String { + if (loading) return "Turnierbeteiligungen werden geladen" + if (rows.isEmpty()) return "Keine Turnierbeteiligungen im gewählten Zeitraum" + val uniqueMembers = rows.mapNotNull { it.memberName }.distinct().size + return "${rows.size} Einträge von $uniqueMembers Mitgliedern im Zeitraum ${range.vueKey}" +} + +internal fun isAutoRegisterSuccess(response: JsonElement?): Boolean { + if (response !is JsonObject) return false + return response["success"]?.let { el -> + when (el) { + is JsonPrimitive -> el.booleanOrNull == true + else -> false + } + } == true +} + +internal fun autoRegisterMessage(response: JsonElement?): String? { + if (response !is JsonObject) return null + return response["message"]?.let { (it as? JsonPrimitive)?.contentOrNull } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/OfficialTournamentsWorkspaceScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/OfficialTournamentsWorkspaceScreen.kt new file mode 100644 index 00000000..2975dc53 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/OfficialTournamentsWorkspaceScreen.kt @@ -0,0 +1,1615 @@ +package de.tt_tagebuch.app.ui + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +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.OutlinedTextField +import androidx.compose.material.Surface +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.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.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.app.util.OfficialTournamentEligibility +import de.tt_tagebuch.shared.api.models.OfficialParsedCompetitionDto +import de.tt_tagebuch.shared.api.models.OfficialParsedTournamentEnvelopeDto +import de.tt_tagebuch.shared.api.models.OfficialPatchTournamentBody +import de.tt_tagebuch.shared.api.models.OfficialParticipantStatusBody +import de.tt_tagebuch.shared.api.models.OfficialTournamentListRowDto +import de.tt_tagebuch.shared.api.models.OfficialUpsertParticipationBody +import de.tt_tagebuch.shared.api.models.Member +import de.tt_tagebuch.shared.api.models.canReadTournaments +import de.tt_tagebuch.shared.api.models.canWriteTournaments +import de.tt_tagebuch.shared.i18n.MobileStrings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private enum class WorkspaceTab { Overview, Competitions, Results } + +private val OfficialPanelBorder = Color(0xFFDBE3F0) +private val OfficialPanelTitle = Color(0xFF17366D) +private val OfficialPanelMuted = Color(0xFF64748B) +private val OfficialRowStripe = Color(0xFFF8FAFC) +private val OfficialSelectedBg = Color(0xFFEEF4FF) +private val OfficialSelectedBorder = Color(0xFFD1DEFD) + +private val OfficialParticipationRangeOrder = listOf( + OfficialParticipationRange.M3, + OfficialParticipationRange.M6, + OfficialParticipationRange.M12, + OfficialParticipationRange.Y2, + OfficialParticipationRange.PREV, + OfficialParticipationRange.ALL, +) + +private fun participationRangeLabel(r: OfficialParticipationRange, tr: (String, String) -> String): String = + when (r) { + OfficialParticipationRange.M3 -> tr("officialTournaments.last3Months", "Letzte 3 Monate") + OfficialParticipationRange.M6 -> tr("officialTournaments.last6Months", "Letzte 6 Monate") + OfficialParticipationRange.M12 -> tr("officialTournaments.last12Months", "Letzte 12 Monate") + OfficialParticipationRange.Y2 -> tr("officialTournaments.last2Years", "Letzte 2 Jahre") + OfficialParticipationRange.PREV -> tr("officialTournaments.previousSeason", "Vorsaison") + OfficialParticipationRange.ALL -> tr("officialTournaments.all", "Alle") + } + +@Composable +private fun OfficialWorkspaceCard( + modifier: Modifier = Modifier.fillMaxWidth(), + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colors.surface, + elevation = 2.dp, + border = BorderStroke(1.dp, OfficialPanelBorder), + ) { + Column( + Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } +} + +@Composable +private fun ColumnScope.OfficialPanelHeader(title: String, subtitle: String) { + Text( + title, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.SemiBold, + color = OfficialPanelTitle, + ) + Text( + subtitle, + style = MaterialTheme.typography.body2, + color = OfficialPanelMuted, + ) +} + +@Composable +private fun TournamentListStatusBadge(st: TournamentListStatus) { + val colors = when (st.className) { + "is-active" -> Triple(Color(0xFFE8F1FF), Color(0xFF2453A6), Color(0xFFBFD2FF)) + "is-upcoming" -> Triple(Color(0xFFE6F6EA), Color(0xFF1E6B34), Color(0xFFB9E2C4)) + "is-past" -> Triple(Color(0xFFF2F4F7), Color(0xFF556070), Color(0xFFD8DEE8)) + else -> Triple(Color(0xFFFFF4DB), Color(0xFF8A5A00), Color(0xFFF2D08A)) + } + Surface( + shape = RoundedCornerShape(999.dp), + color = colors.first, + border = BorderStroke(1.dp, colors.third), + ) { + Text( + st.label, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.SemiBold, + color = colors.second, + ) + } +} + +@Composable +internal fun OfficialTournamentsWorkspaceScreen( + clubId: Int, + canWrite: Boolean, + dependencies: AppDependencies, + tr: (String, String) -> String, + onClose: () -> Unit, +) { + val scope = rememberCoroutineScope() + val officialState by dependencies.officialTournamentsReadManager.state.collectAsState() + val membersState by dependencies.membersManager.state.collectAsState() + + var selectedTournamentId by remember { mutableStateOf(null) } + var parsed by remember { mutableStateOf(null) } + var parsedLoading by remember { mutableStateOf(false) } + var parsedError by remember { mutableStateOf(null) } + val participationMap = remember { mutableStateMapOf() } + + var tournamentSearch by remember { mutableStateOf("") } + var showOlderTournaments by remember { mutableStateOf(false) } + var editingTournamentId by remember { mutableStateOf(null) } + var editingTitle by remember { mutableStateOf("") } + + var showHistory by remember { mutableStateOf(true) } + var participationRange by remember { mutableStateOf(OfficialParticipationRange.ALL) } + var clubHistoryRows by remember { mutableStateOf>(emptyList()) } + var loadingClubParticipations by remember { mutableStateOf(false) } + + var pickedPdfBytes by remember { mutableStateOf(null) } + var pickedPdfName by remember { mutableStateOf("") } + var pdfUploading by remember { mutableStateOf(false) } + + var activeTab by remember { mutableStateOf(WorkspaceTab.Overview) } + var participantsFilter by remember { mutableStateOf("all") } + val expandedCompetitionKeys = remember { mutableStateMapOf() } + + var showMemberDialog by remember { mutableStateOf(false) } + var selectedMemberIds by remember { mutableStateOf(setOf()) } + var memberRecommendations by remember { mutableStateOf(mapOf>()) } + var dialogMemberId by remember { mutableStateOf(null) } + + var busyAuto by remember { mutableStateOf(false) } + var infoMessage by remember { mutableStateOf(null) } + var infoTitle by remember { mutableStateOf("") } + var showInfoDialog by remember { mutableStateOf(false) } + + var batchAction by remember { mutableStateOf(null) } + var deleteTarget by remember { mutableStateOf(null) } + + fun memberNameById(id: Int): String { + val m = membersState.members.find { it.id == id } + return if (m != null) "${m.firstName} ${m.lastName}" else "#$id" + } + + fun mapSnapshot(): Map = participationMap.toMap() + + LaunchedEffect(clubId) { + dependencies.membersManager.loadMembers(clubId) + dependencies.officialTournamentsReadManager.load(clubId) + } + + LaunchedEffect(clubId, participationRange) { + loadingClubParticipations = true + runCatching { + val buckets = withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.listParticipationSummary(clubId) + } + clubHistoryRows = computeClubHistoryRows(buckets, participationRange) + } + loadingClubParticipations = false + } + + LaunchedEffect(clubId, selectedTournamentId) { + val id = selectedTournamentId ?: run { + parsed = null + parsedError = null + return@LaunchedEffect + } + parsedLoading = true + parsedError = null + runCatching { + dependencies.officialTournamentsApi.getParsed(clubId, id) + }.onSuccess { parsed = it } + .onFailure { + parsed = null + parsedError = it.message + } + parsedLoading = false + } + + LaunchedEffect(parsed) { + participationMap.clear() + val p = parsed ?: return@LaunchedEffect + for ((k, v) in buildParticipationMapFromApi(p.participation)) { + participationMap[k] = v + } + } + + suspend fun reloadParsed(): OfficialParsedTournamentEnvelopeDto? { + val id = selectedTournamentId ?: return null + return withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.getParsed(clubId, id) + }.also { p -> + parsed = p + } + } + + fun showInfo(title: String, message: String) { + infoTitle = title + infoMessage = message + showInfoDialog = true + } + + val filteredList = remember(officialState.tournaments, tournamentSearch, showOlderTournaments, selectedTournamentId) { + filterTournamentList(officialState.tournaments, tournamentSearch, showOlderTournaments, selectedTournamentId) + } + + val activeMembers = remember(membersState.members) { + membersState.members.filter { it.active }.sortedWith { a, b -> OfficialTournamentEligibility.compareMembers(a, b) } + } + + val androidContext = LocalContext.current + val screenWide = LocalConfiguration.current.screenWidthDp >= 640 + val pickPdf = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + val bytes = androidContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } + val name = uri.lastPathSegment?.substringAfterLast(':')?.ifBlank { null } + ?: uri.lastPathSegment?.substringAfterLast('/')?.ifBlank { null } + ?: "turnier.pdf" + bytes to name + } + }.onSuccess { (bytes, name) -> + if (bytes != null) { + pickedPdfBytes = bytes + pickedPdfName = name + } + }.onFailure { showInfo(tr("common.error", "Fehler"), it.message ?: "") } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton(onClick = onClose) { Text(tr("mobile.back", "Zurück")) } + + val adminTopRow = canWrite && screenWide + val savedBlock: @Composable ColumnScope.() -> Unit = { + OfficialTournamentSavedEventsBlock( + tr = tr, + officialStateLoading = officialState.isLoading, + filteredList = filteredList, + tournamentSearch = tournamentSearch, + onTournamentSearchChange = { tournamentSearch = it }, + showOlderTournaments = showOlderTournaments, + onShowOlderChange = { showOlderTournaments = it }, + totalTournaments = officialState.tournaments.size, + selectedTournamentId = selectedTournamentId, + onSelectTournament = { selectedTournamentId = it }, + onTabReset = { activeTab = WorkspaceTab.Overview }, + editingTournamentId = editingTournamentId, + editingTitle = editingTitle, + onEditingTitleChange = { editingTitle = it }, + onStartTitleEdit = { t -> + editingTournamentId = t.id + editingTitle = t.title ?: "" + }, + onCancelTitleEdit = { editingTournamentId = null }, + canWrite = canWrite, + onDeleteRequest = { deleteTarget = it }, + clubId = clubId, + scope = scope, + dependencies = dependencies, + reloadParsed = ::reloadParsed, + showInfo = ::showInfo, + ) + } + val importBlock: @Composable ColumnScope.() -> Unit = { + OfficialTournamentImportBlock( + tr = tr, + pdfUploading = pdfUploading, + pickedPdfBytes = pickedPdfBytes, + pickedPdfName = pickedPdfName, + onPickPdf = { pickPdf.launch("application/pdf") }, + onUpload = { + pickedPdfBytes?.let { bytes -> + scope.launch { + pdfUploading = true + runCatching { + val res = withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.uploadPdf( + clubId, + bytes, + pickedPdfName.ifBlank { "turnier.pdf" }, + ) + } + val newId = res.id.toIntOrNull() ?: res.id.toDoubleOrNull()?.toInt() + if (newId != null) { + pickedPdfBytes = null + pickedPdfName = "" + dependencies.officialTournamentsReadManager.load(clubId) + selectedTournamentId = newId + activeTab = WorkspaceTab.Overview + showInfo( + tr("common.success", "Erfolg"), + tr("officialTournaments.uploadOk", "Turnier importiert."), + ) + } else { + showInfo(tr("common.error", "Fehler"), tr("officialTournaments.uploadBadId", "Ungültige Antwort vom Server.")) + } + }.onFailure { showInfo(tr("common.error", "Fehler"), it.message ?: "") } + pdfUploading = false + } + } + }, + ) + } + + if (adminTopRow) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.weight(1f)) { + OfficialWorkspaceCard(Modifier.fillMaxWidth(), content = importBlock) + } + Box(Modifier.weight(1f)) { + OfficialWorkspaceCard(Modifier.fillMaxWidth(), content = savedBlock) + } + } + } else { + if (canWrite) { + OfficialWorkspaceCard(content = importBlock) + } + OfficialWorkspaceCard(content = savedBlock) + } + + OfficialWorkspaceCard { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + tr("officialTournaments.participations", "Turnierbeteiligungen"), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.SemiBold, + color = OfficialPanelTitle, + ) + Text( + historySummaryText(loadingClubParticipations, clubHistoryRows, participationRange), + style = MaterialTheme.typography.caption, + color = OfficialPanelMuted, + ) + } + OutlinedButton(onClick = { showHistory = !showHistory }) { + Text(if (showHistory) tr("officialTournaments.hideHistory", "Ausblenden") else tr("officialTournaments.showHistory", "Einblenden")) + } + } + if (showHistory) { + var participationRangeMenuOpen by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + tr("officialTournaments.timeRange", "Zeitraum"), + style = MaterialTheme.typography.body2, + color = OfficialPanelMuted, + ) + Box { + OutlinedButton(onClick = { participationRangeMenuOpen = true }) { + Text(participationRangeLabel(participationRange, tr)) + } + DropdownMenu( + expanded = participationRangeMenuOpen, + onDismissRequest = { participationRangeMenuOpen = false }, + ) { + OfficialParticipationRangeOrder.forEach { r -> + DropdownMenuItem( + onClick = { + participationRange = r + participationRangeMenuOpen = false + }, + ) { + Text(participationRangeLabel(r, tr)) + } + } + } + } + } + if (loadingClubParticipations) { + CircularProgressIndicator(Modifier.padding(8.dp)) + } else { + HistoryTable(clubHistoryRows, tr) + } + } + } + + val p = parsed + val tid = selectedTournamentId + if (p == null || tid == null || p.parsedData?.competitions.isNullOrEmpty()) { + OfficialWorkspaceCard { + if (parsedLoading) { + CircularProgressIndicator(Modifier.padding(8.dp)) + } + parsedError?.let { Text(it, color = MaterialTheme.colors.error) } + if (!parsedLoading && tid == null) { + Text(tr("officialTournaments.noSelectionTitle", "Kein aktives Turnier ausgewählt"), fontWeight = FontWeight.SemiBold, color = OfficialPanelTitle) + Text( + tr("officialTournaments.noSelectionBody", "Wähle oben ein gespeichertes Turnier aus."), + style = MaterialTheme.typography.body2, + color = OfficialPanelMuted, + ) + } + if (!parsedLoading && tid != null && (p == null || p.parsedData?.competitions.isNullOrEmpty())) { + Text( + tr("officialTournaments.noParsedData", "Für dieses Turnier liegen keine Konkurrenzen vor oder die Daten konnten nicht geladen werden."), + style = MaterialTheme.typography.body2, + color = OfficialPanelMuted, + ) + } + } + } else { + OfficialWorkspaceCard { + val pd = p.parsedData!! + val notices = workflowNotices(mapSnapshot()) + val (regSum, partSum) = registrationSummary(mapSnapshot()) + val pending = pendingAutoRegistrationCount(mapSnapshot()) + val groupsCount = participantsGroups(participantsRows(p, mapSnapshot(), "all") { memberNameById(it) }).size + + Text(tr("officialTournaments.activeTournament", "Aktives Turnier"), style = MaterialTheme.typography.overline) + Text(pd.title ?: "–", fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + Text(pd.termin ?: "–", style = MaterialTheme.typography.caption) + val loc = tournamentLocationLabel(pd) + if (loc.isNotBlank()) Text(loc, style = MaterialTheme.typography.caption) + val rdi = registrationDeadlineInfo(pd) + Text(rdi.label, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.primary) + } + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + notices.forEach { n -> + Text(n.label, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, modifier = Modifier.padding(end = 6.dp)) + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatMini(tr("officialTournaments.statPending", "Offene Meldungen"), pending.toString()) + StatMini(tr("officialTournaments.statRegistered", "Angemeldet"), regSum.toString()) + StatMini(tr("officialTournaments.statPlayed", "Teilgenommen"), partSum.toString()) + StatMini(tr("officialTournaments.statMembers", "Mitglieder (Übersicht)"), groupsCount.toString()) + } + + val primary = primaryHeaderAction(mapSnapshot()) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (primary == "auto-register" && canWrite) { + Button( + onClick = { + scope.launch { + if (!canAutoRegister(tid, mapSnapshot(), pd)) return@launch + busyAuto = true + runCatching { + val res = withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.autoRegister(clubId, tid) + } + if (isAutoRegisterSuccess(res)) { + reloadParsed() + dependencies.officialTournamentsReadManager.load(clubId) + showInfo( + tr("common.success", "Erfolg"), + autoRegisterMessage(res) ?: tr("officialTournaments.autoRegisterOk", "Automatische Anmeldung abgeschlossen."), + ) + } else { + showInfo(tr("common.error", "Fehler"), tr("officialTournaments.autoRegisterFail", "Anmeldung fehlgeschlagen.")) + } + }.onFailure { showInfo(tr("common.error", "Fehler"), it.message ?: "") } + busyAuto = false + } + }, + enabled = !busyAuto && canAutoRegister(tid, mapSnapshot(), pd), + ) { Text(if (busyAuto) "…" else tr("officialTournaments.autoRegisterPrimary", "Automatisch anmelden")) } + } else if (primary == "results") { + Button(onClick = { activeTab = WorkspaceTab.Results }) { + Text(tr("officialTournaments.resultsMaintain", "Ergebnisse pflegen")) + } + } else { + Button( + onClick = { showMemberDialog = true }, + enabled = activeMembers.isNotEmpty(), + ) { Text(tr("officialTournaments.selectMembers", "Mitglieder auswählen")) } + } + if (primary != "members" && canWrite) { + OutlinedButton(onClick = { showMemberDialog = true }, enabled = activeMembers.isNotEmpty()) { + Text(tr("officialTournaments.selectMembers", "Mitglieder auswählen")) + } + } + if (primary != "auto-register" && canWrite) { + OutlinedButton( + onClick = { + scope.launch { + if (!canAutoRegister(tid, mapSnapshot(), pd)) return@launch + busyAuto = true + runCatching { + val res = withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.autoRegister(clubId, tid) + } + if (isAutoRegisterSuccess(res)) { + reloadParsed() + dependencies.officialTournamentsReadManager.load(clubId) + showInfo(tr("common.success", "Erfolg"), autoRegisterMessage(res) ?: "") + } + }.onFailure { showInfo(tr("common.error", "Fehler"), it.message ?: "") } + busyAuto = false + } + }, + enabled = !busyAuto && canAutoRegister(tid, mapSnapshot(), pd), + ) { Text(if (busyAuto) "…" else tr("officialTournaments.autoRegister", "Automatisch anmelden")) } + } + if (primary != "results") { + OutlinedButton( + onClick = { activeTab = WorkspaceTab.Results }, + enabled = regSum > 0, + ) { Text(tr("officialTournaments.results", "Ergebnisse")) } + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TabButton(tr("officialTournaments.tabOverview", "Übersicht"), activeTab == WorkspaceTab.Overview) { activeTab = WorkspaceTab.Overview } + TabButton(tr("officialTournaments.competitions", "Konkurrenzen"), activeTab == WorkspaceTab.Competitions) { activeTab = WorkspaceTab.Competitions } + TabButton(tr("officialTournaments.results", "Ergebnisse"), activeTab == WorkspaceTab.Results) { activeTab = WorkspaceTab.Results } + } + + when (activeTab) { + WorkspaceTab.Overview -> OverviewTabContent( + clubId = clubId, + tournamentId = tid, + parsed = p, + members = membersState.members, + participationMap = participationMap, + canWrite = canWrite, + participantsFilter = participantsFilter, + onFilterChange = { participantsFilter = it }, + dependencies = dependencies, + tr = tr, + memberNameById = ::memberNameById, + scope = scope, + onReload = { scope.launch { reloadParsed() } }, + onShowInfo = ::showInfo, + onBatchRequest = { batchAction = it }, + ) + WorkspaceTab.Competitions -> CompetitionsTabContent( + parsed = p, + members = membersState.members, + participationMap = participationMap, + expanded = expandedCompetitionKeys, + clubId = clubId, + tournamentId = tid, + canWrite = canWrite, + dependencies = dependencies, + tr = tr, + memberNameById = ::memberNameById, + scope = scope, + onReload = { scope.launch { reloadParsed() } }, + onShowInfo = ::showInfo, + ) + WorkspaceTab.Results -> ResultsTabContent( + clubId = clubId, + tournamentId = tid, + parsed = p, + participationMap = participationMap, + canWrite = canWrite, + dependencies = dependencies, + tr = tr, + memberNameById = ::memberNameById, + scope = scope, + onReload = { scope.launch { reloadParsed() } }, + onShowInfo = ::showInfo, + ) + } + } + } + } + + if (showMemberDialog) { + MemberSelectionDialog( + activeMembers = activeMembers, + selectedIds = selectedMemberIds, + onSelectedIdsChange = { selectedMemberIds = it }, + dialogMemberId = dialogMemberId, + onDialogMemberIdChange = { dialogMemberId = it }, + parsed = parsed, + recommendations = memberRecommendations, + onRecommendationsChange = { memberRecommendations = it }, + onDismiss = { showMemberDialog = false }, + tr = tr, + ) + } + + if (showInfoDialog && infoMessage != null) { + AlertDialog( + onDismissRequest = { showInfoDialog = false }, + title = { Text(infoTitle) }, + text = { Text(infoMessage!!) }, + confirmButton = { TextButton(onClick = { showInfoDialog = false }) { Text(tr("common.ok", "OK")) } }, + ) + } + + batchAction?.let { action -> + val pBatch = parsed + if (pBatch == null || selectedTournamentId == null) return@let + val rows = when (action) { + "register" -> participantsRows(pBatch, mapSnapshot(), "open") { memberNameById(it) }.filter { it.wants && !it.registered && !it.participated } + "participate" -> participantsRows(pBatch, mapSnapshot(), "registered") { memberNameById(it) } + "reset" -> participantsRows(pBatch, mapSnapshot(), "participated") { memberNameById(it) } + else -> emptyList() + } + AlertDialog( + onDismissRequest = { batchAction = null }, + title = { Text(tr("officialTournaments.batchTitle", "Sammelaktion")) }, + text = { Text(tr("officialTournaments.batchConfirm", "{n} Einträge wirklich ausführen?").replace("{n}", rows.size.toString())) }, + confirmButton = { + TextButton( + onClick = { + val act = batchAction + batchAction = null + if (act == null) return@TextButton + scope.launch { + val tournamentIdForBatch = selectedTournamentId ?: return@launch + var ok = 0 + val failures = mutableListOf() + for (row in rows) { + runCatching { + val parts = row.key.split("-") + val cid = parts[0].toInt() + val mid = parts[1].toInt() + val res = withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.updateParticipantStatus( + clubId, + tournamentIdForBatch, + OfficialParticipantStatusBody( + competitionId = cid, + memberId = mid, + action = when (act) { + "register" -> "register" + "participate" -> "participate" + else -> "reset" + }, + ), + ) + } + val st = res.status + if (res.success && st != null) { + participationMap[row.key] = ParticipationState(st.wants, st.registered, st.participated, st.placement) + ok++ + } + }.onFailure { failures.add("${row.memberName} / ${row.competitionName}") } + } + if (failures.isNotEmpty()) { + showInfo(tr("officialTournaments.batchPartial", "Teilweise erfolgreich"), "$ok / ${rows.size}\n${failures.joinToString("\n")}") + } else { + showInfo(tr("common.success", "Erfolg"), tr("officialTournaments.batchDone", "{n} Einträge aktualisiert.").replace("{n}", ok.toString())) + } + } + }, + ) { Text(tr("common.confirm", "Bestätigen")) } + }, + dismissButton = { TextButton(onClick = { batchAction = null }) { Text(tr("common.cancel", "Abbrechen")) } }, + ) + } + + deleteTarget?.let { target -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text(tr("officialTournaments.deleteTournamentTitle", "Turnier löschen")) }, + text = { Text("${target.title ?: ""} (ID ${target.id})") }, + confirmButton = { + TextButton( + onClick = { + val id = target.id + deleteTarget = null + scope.launch { + runCatching { + withContext(Dispatchers.IO) { dependencies.officialTournamentsApi.deleteTournament(clubId, id) } + if (selectedTournamentId == id) selectedTournamentId = null + dependencies.officialTournamentsReadManager.load(clubId) + showInfo(tr("common.success", "Erfolg"), tr("common.deleted", "Gelöscht.")) + }.onFailure { showInfo(tr("common.error", "Fehler"), it.message ?: "") } + } + }, + ) { Text(tr("common.delete", "Löschen")) } + }, + dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text(tr("common.cancel", "Abbrechen")) } }, + ) + } +} + +@Composable +private fun ColumnScope.OfficialTournamentImportBlock( + tr: (String, String) -> String, + pdfUploading: Boolean, + pickedPdfBytes: ByteArray?, + pickedPdfName: String, + onPickPdf: () -> Unit, + onUpload: () -> Unit, +) { + OfficialPanelHeader( + title = tr("officialTournaments.importTitle", "Turnier importieren"), + subtitle = tr("officialTournaments.importHint", "PDF hochladen und als neues offizielles Turnier anlegen."), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onPickPdf, enabled = !pdfUploading) { + Text(tr("officialTournaments.choosePdf", "PDF auswählen")) + } + Button( + onClick = onUpload, + enabled = pickedPdfBytes != null && !pdfUploading, + ) { + Text(if (pdfUploading) "…" else tr("officialTournaments.uploadPdf", "PDF hochladen")) + } + } + if (pickedPdfName.isNotBlank()) { + Text( + pickedPdfName, + style = MaterialTheme.typography.caption, + color = OfficialPanelMuted, + ) + } +} + +@Composable +private fun ColumnScope.OfficialTournamentSavedEventsBlock( + tr: (String, String) -> String, + officialStateLoading: Boolean, + filteredList: List, + tournamentSearch: String, + onTournamentSearchChange: (String) -> Unit, + showOlderTournaments: Boolean, + onShowOlderChange: (Boolean) -> Unit, + totalTournaments: Int, + selectedTournamentId: Int?, + onSelectTournament: (Int) -> Unit, + onTabReset: () -> Unit, + editingTournamentId: Int?, + editingTitle: String, + onEditingTitleChange: (String) -> Unit, + onStartTitleEdit: (OfficialTournamentListRowDto) -> Unit, + onCancelTitleEdit: () -> Unit, + canWrite: Boolean, + onDeleteRequest: (OfficialTournamentListRowDto) -> Unit, + clubId: Int, + scope: kotlinx.coroutines.CoroutineScope, + dependencies: AppDependencies, + reloadParsed: suspend () -> OfficialParsedTournamentEnvelopeDto?, + showInfo: (String, String) -> Unit, +) { + OfficialPanelHeader( + title = tr("officialTournaments.savedEvents", "Gespeicherte Veranstaltungen"), + subtitle = tr("officialTournaments.savedEventsHint", "Turnier auswählen, umbenennen oder löschen."), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = tournamentSearch, + onValueChange = onTournamentSearchChange, + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text(tr("officialTournaments.searchTournament", "Turnier suchen")) }, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = showOlderTournaments, onCheckedChange = onShowOlderChange) + Text(tr("officialTournaments.showPast", "Vergangene anzeigen"), style = MaterialTheme.typography.caption) + } + Text("${filteredList.size} / $totalTournaments", style = MaterialTheme.typography.caption) + } + if (officialStateLoading) { + CircularProgressIndicator(Modifier.padding(8.dp)) + } else if (filteredList.isEmpty()) { + Text( + if (tournamentSearch.isNotBlank()) tr("officialTournaments.noMatchingList", "Keine passenden Turniere gefunden.") + else tr("officialTournaments.noEvents", "Keine importierten Turniere."), + color = OfficialPanelMuted, + ) + } else { + filteredList.forEach { t -> + val selected = selectedTournamentId == t.id + val st = tournamentListStatus(selectedTournamentId, t) + val titleMuted = st.className == "is-past" && !selected + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + shape = RoundedCornerShape(8.dp), + color = if (selected) OfficialSelectedBg else Color.Transparent, + border = if (selected) BorderStroke(1.dp, OfficialSelectedBorder) else null, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (editingTournamentId == t.id && canWrite) { + OutlinedTextField( + value = editingTitle, + onValueChange = onEditingTitleChange, + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text(tr("officialTournaments.tournamentTitle", "Titel")) }, + ) + TextButton( + onClick = { + val newTitle = editingTitle.trim() + onCancelTitleEdit() + scope.launch { + if (newTitle != (t.title ?: "").trim()) { + runCatching { + withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.patchTournament( + clubId, + t.id, + OfficialPatchTournamentBody(title = newTitle.ifBlank { null }), + ) + } + dependencies.officialTournamentsReadManager.load(clubId) + if (selectedTournamentId == t.id) reloadParsed() + }.onFailure { showInfo(tr("common.error", "Fehler"), it.message ?: "") } + } + } + }, + ) { Text(tr("common.save", "OK")) } + } else { + Text( + t.title ?: (tr("officialTournaments.tournament", "Turnier") + " #${t.id}"), + modifier = Modifier + .weight(1f) + .clickable { + onSelectTournament(t.id) + onTabReset() + }, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = if (titleMuted) OfficialPanelMuted else MaterialTheme.colors.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val dateLine = tournamentListRawDate(t).ifBlank { null } + if (dateLine != null) { + Text( + " — $dateLine", + style = MaterialTheme.typography.caption, + color = if (titleMuted) OfficialPanelMuted else MaterialTheme.colors.onSurface.copy(alpha = 0.75f), + ) + } + TournamentListStatusBadge(st) + if (canWrite) { + TextButton(onClick = { onStartTitleEdit(t) }) { Text("✏️") } + TextButton(onClick = { onDeleteRequest(t) }) { Text("🗑️") } + } + } + } + } + } + } +} + +@Composable +private fun StatMini(label: String, value: String) { + Column(Modifier.padding(4.dp)) { + Text(label, style = MaterialTheme.typography.caption) + Text(value, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) { + TextButton(onClick = onClick) { + Text(label, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) + } +} + +@Composable +private fun HistoryTable(rows: List, tr: (String, String) -> String) { + val headerBg = MaterialTheme.colors.primary + val headerFg = MaterialTheme.colors.onPrimary + Column(Modifier.fillMaxWidth()) { + Row( + Modifier + .fillMaxWidth() + .background(headerBg) + .padding(horizontal = 10.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + tr("officialTournaments.member", "Mitglied"), + Modifier.widthIn(88.dp), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.body2, + color = headerFg, + ) + Text( + tr("officialTournaments.tournament", "Turnier"), + Modifier.weight(1f), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.body2, + color = headerFg, + ) + Text( + tr("officialTournaments.competition", "Konkurrenz"), + Modifier.weight(1f), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.body2, + color = headerFg, + ) + Text( + tr("officialTournaments.date", "Datum"), + Modifier.widthIn(72.dp), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.body2, + color = headerFg, + ) + Text( + tr("officialTournaments.placement", "Platzierung"), + Modifier.widthIn(52.dp), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.body2, + color = headerFg, + ) + } + rows.forEachIndexed { i, r -> + Row( + Modifier + .fillMaxWidth() + .background(if (i % 2 == 1) OfficialRowStripe else Color.White) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + r.memberName ?: "–", + Modifier.widthIn(88.dp), + style = MaterialTheme.typography.body2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + r.tournamentName ?: "–", + Modifier.weight(1f), + style = MaterialTheme.typography.body2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + r.competitionName ?: "–", + Modifier.weight(1f), + style = MaterialTheme.typography.body2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text(r.date ?: "–", Modifier.widthIn(72.dp), style = MaterialTheme.typography.body2) + Text(r.placement ?: "–", Modifier.widthIn(52.dp), style = MaterialTheme.typography.body2) + } + Divider(color = OfficialPanelBorder.copy(alpha = 0.6f)) + } + } +} + +@Composable +private fun OverviewTabContent( + clubId: Int, + tournamentId: Int, + parsed: OfficialParsedTournamentEnvelopeDto, + members: List, + participationMap: MutableMap, + canWrite: Boolean, + participantsFilter: String, + onFilterChange: (String) -> Unit, + dependencies: AppDependencies, + tr: (String, String) -> String, + memberNameById: (Int) -> String, + scope: kotlinx.coroutines.CoroutineScope, + onReload: () -> Unit, + onShowInfo: (String, String) -> Unit, + onBatchRequest: (String) -> Unit, +) { + val mapSnap = participationMap.toMap() + val rows = remember(parsed, mapSnap, participantsFilter) { + participantsRows(parsed, mapSnap, participantsFilter, memberNameById) + } + val groups = remember(rows) { participantsGroups(rows) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(tr("officialTournaments.participantsHeading", "Teilnehmer"), fontWeight = FontWeight.SemiBold) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(tr("officialTournaments.statusFilter", "Status:"), style = MaterialTheme.typography.caption) + listOf("all", "open", "registered", "participated").forEach { key -> + val label = when (key) { + "all" -> tr("officialTournaments.filterAll", "Ohne Filter") + "open" -> tr("officialTournaments.filterOpen", "Offen") + "registered" -> tr("officialTournaments.filterRegistered", "Angemeldet") + else -> tr("officialTournaments.filterPlayed", "Hat gespielt") + } + TextButton(onClick = { onFilterChange(key) }) { + Text(label, fontWeight = if (participantsFilter == key) FontWeight.Bold else FontWeight.Normal) + } + } + } + if (canWrite) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (participantsFilter == "open") { + OutlinedButton( + onClick = { onBatchRequest("register") }, + enabled = rows.any { it.wants }, + ) { Text(tr("officialTournaments.batchRegister", "Alle Interessierten als angemeldet markieren"), style = MaterialTheme.typography.caption) } + } + if (participantsFilter == "registered") { + OutlinedButton(onClick = { onBatchRequest("participate") }, enabled = rows.isNotEmpty()) { + Text(tr("officialTournaments.batchParticipate", "Alle als teilgenommen markieren"), style = MaterialTheme.typography.caption) + } + } + if (participantsFilter == "participated") { + OutlinedButton(onClick = { onBatchRequest("reset") }, enabled = rows.isNotEmpty()) { + Text(tr("officialTournaments.batchReset", "Alle zurücksetzen"), style = MaterialTheme.typography.caption) + } + } + } + } + + if (groups.isEmpty()) { + Text(tr("officialTournaments.noFilterRows", "Keine Einträge für den gewählten Filter."), style = MaterialTheme.typography.caption) + } else { + groups.forEach { g -> + Text(g.memberName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 8.dp)) + g.items.forEach { item -> + ParticipantItemRow( + clubId = clubId, + tournamentId = tournamentId, + item = item, + groupMemberId = g.memberId, + canWrite = canWrite, + dependencies = dependencies, + participationMap = participationMap, + scope = scope, + onReload = onReload, + onShowInfo = onShowInfo, + tr = tr, + onMarkInterested = { + scope.launch { + val comps = parsed.parsedData?.competitions.orEmpty() + for (c in comps) { + val m = members.find { it.id == g.memberId } ?: continue + if (!OfficialTournamentEligibility.isEligibleForCompetition(m, c, parsed)) continue + val key = participationKey(c.id, g.memberId) + val cur = getParticipation(participationMap, c.id, g.memberId) + if (cur.wants || cur.registered || cur.participated) continue + runCatching { + withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.upsertParticipation( + clubId, + tournamentId, + OfficialUpsertParticipationBody( + competitionId = c.id, + memberId = g.memberId, + wants = true, + registered = false, + participated = false, + placement = null, + ), + ) + } + participationMap[key] = ParticipationState(true, false, false, null) + }.onFailure { onShowInfo(tr("common.error", "Fehler"), it.message ?: "") } + } + onReload() + } + }, + ) + Divider() + } + } + } + } +} + +@Composable +private fun ParticipantItemRow( + clubId: Int, + tournamentId: Int, + item: ParticipantTableRow, + groupMemberId: Int, + canWrite: Boolean, + dependencies: AppDependencies, + participationMap: MutableMap, + scope: kotlinx.coroutines.CoroutineScope, + onReload: () -> Unit, + onShowInfo: (String, String) -> Unit, + tr: (String, String) -> String, + onMarkInterested: () -> Unit, +) { + var placementDraft by remember(item.key, item.placement) { mutableStateOf(item.placement.orEmpty()) } + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Column( + Modifier + .weight(1f) + .defaultMinSize(minWidth = 160.dp), + ) { + Text(item.competitionName, style = MaterialTheme.typography.body2) + Text(item.start, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)) + } + Column(Modifier.widthIn(100.dp)) { + val status = when { + item.participated -> tr("officialTournaments.statusPlayed", "Hat gespielt") + item.registered -> tr("officialTournaments.statusRegistered", "Angemeldet") + item.wants -> tr("officialTournaments.statusWants", "Möchte teilnehmen") + else -> tr("officialTournaments.statusNone", "Nicht interessiert") + } + Text(status, style = MaterialTheme.typography.caption) + } + Column(Modifier.widthIn(120.dp)) { + if (!canWrite) return@Column + when { + !item.participated && !item.registered && !item.wants -> { + TextButton(onClick = onMarkInterested) { + Text(tr("officialTournaments.interested", "Interessiert"), style = MaterialTheme.typography.caption) + } + } + !item.participated && !item.registered && item.wants -> { + StatusActionButton(clubId, tournamentId, item, "register", tr("officialTournaments.registeredShort", "Angemeldet"), participationMap, scope, dependencies, onReload, onShowInfo, tr) + } + item.registered && !item.participated -> { + StatusActionButton(clubId, tournamentId, item, "participate", tr("officialTournaments.playedShort", "Teilgenommen"), participationMap, scope, dependencies, onReload, onShowInfo, tr) + } + item.participated -> { + StatusActionButton(clubId, tournamentId, item, "reset", tr("officialTournaments.resetShort", "Zurücksetzen"), participationMap, scope, dependencies, onReload, onShowInfo, tr) + } + else -> Text(tr("officialTournaments.noAction", "Keine Aktion"), style = MaterialTheme.typography.caption) + } + } + Column(Modifier.wrapContentWidth(Alignment.End)) { + if (item.participated && canWrite) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + OutlinedTextField( + value = placementDraft, + onValueChange = { placementDraft = it }, + modifier = Modifier.width(72.dp), + singleLine = true, + label = { Text(tr("officialTournaments.placement", "Platz"), style = MaterialTheme.typography.caption) }, + ) + TextButton( + onClick = { + scope.launch { + val parts = item.key.split("-") + val cid = parts[0].toInt() + val mid = parts[1].toInt() + val cur = getParticipation(participationMap, cid, mid) + runCatching { + withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.upsertParticipation( + clubId, + tournamentId, + OfficialUpsertParticipationBody( + competitionId = cid, + memberId = mid, + wants = cur.wants, + registered = cur.registered, + participated = cur.participated, + placement = placementDraft.trim().ifBlank { null }, + ), + ) + } + participationMap[item.key] = cur.copy(placement = placementDraft.trim().ifBlank { null }) + }.onFailure { onShowInfo(tr("common.error", "Fehler"), it.message ?: "") } + } + }, + ) { Text("OK") } + } + } else { + Text(item.placement ?: "–", style = MaterialTheme.typography.caption) + } + } + } +} + +@Composable +private fun StatusActionButton( + clubId: Int, + tournamentId: Int, + item: ParticipantTableRow, + action: String, + label: String, + participationMap: MutableMap, + scope: kotlinx.coroutines.CoroutineScope, + dependencies: AppDependencies, + onReload: () -> Unit, + onShowInfo: (String, String) -> Unit, + tr: (String, String) -> String, +) { + TextButton( + onClick = { + scope.launch { + val parts = item.key.split("-") + val cid = parts[0].toInt() + val mid = parts[1].toInt() + runCatching { + val res = withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.updateParticipantStatus( + clubId, + tournamentId, + OfficialParticipantStatusBody(competitionId = cid, memberId = mid, action = action), + ) + } + val st = res.status + if (res.success && st != null) { + participationMap[item.key] = ParticipationState(st.wants, st.registered, st.participated, st.placement) + } + }.onFailure { onShowInfo(tr("common.error", "Fehler"), it.message ?: "") } + } + }, + ) { Text(label, style = MaterialTheme.typography.caption) } +} + +@Composable +private fun CompetitionsTabContent( + parsed: OfficialParsedTournamentEnvelopeDto, + members: List, + participationMap: MutableMap, + expanded: MutableMap, + clubId: Int, + tournamentId: Int, + canWrite: Boolean, + dependencies: AppDependencies, + tr: (String, String) -> String, + memberNameById: (Int) -> String, + scope: kotlinx.coroutines.CoroutineScope, + onReload: () -> Unit, + onShowInfo: (String, String) -> Unit, +) { + val comps = parsed.parsedData?.competitions.orEmpty() + Text(tr("officialTournaments.competitions", "Konkurrenzen"), fontWeight = FontWeight.SemiBold) + comps.forEachIndexed { idx, c -> + val ek = "c-$idx" + val exp = expanded[ek] == true + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { expanded[ek] = !exp }) { Text(if (exp) "▾" else "▸") } + Column(Modifier.weight(1f)) { + Text(c.ageClassCompetition ?: c.altersklasseWettbewerb ?: "–", style = MaterialTheme.typography.body2) + Text(c.startTime ?: c.startzeit ?: "–", style = MaterialTheme.typography.caption) + } + val sum = competitionStatusSummary(c, parsed, members, participationMap.toMap()) + Text("${sum.interested} / ${sum.registered} / ${sum.participated}", style = MaterialTheme.typography.caption) + Text(c.entryFee ?: c.startgeld ?: "–", style = MaterialTheme.typography.caption, modifier = Modifier.widthIn(48.dp)) + } + if (exp) { + Column(Modifier.padding(start = 32.dp, bottom = 8.dp)) { + Text("${tr("officialTournaments.deadlineDate", "Meldeschluss")}: ${c.registrationDeadlineDate ?: c.meldeschlussDatum ?: "–"}", style = MaterialTheme.typography.caption) + Text("${tr("officialTournaments.deadlineOnline", "Online bis")}: ${c.registrationDeadlineOnline ?: c.meldeschlussOnline ?: "–"}", style = MaterialTheme.typography.caption) + Text("${tr("officialTournaments.cutoffDate", "Stichtag")}: ${c.cutoffDate ?: c.stichtag ?: "–"}", style = MaterialTheme.typography.caption) + Text("${tr("officialTournaments.openTo", "Offen für")}: ${c.openTo ?: c.offenFuer ?: "–"}", style = MaterialTheme.typography.caption) + Text("${tr("officialTournaments.preliminaryRound", "Vorrunde")}: ${c.preliminaryRound ?: c.vorrunde ?: "–"}", style = MaterialTheme.typography.caption) + Text("${tr("officialTournaments.finalRound", "Endrunde")}: ${c.finalRound ?: c.endrunde ?: "–"}", style = MaterialTheme.typography.caption) + Text(tr("officialTournaments.activeRegistrations", "Aktive Meldungen"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 6.dp)) + val activeRows = activeCompetitionMemberRows(c, parsed, members, participationMap.toMap(), memberNameById) + if (activeRows.isEmpty()) { + Text(tr("officialTournaments.noActiveRegs", "Noch keine aktiven Meldungen in dieser Konkurrenz."), style = MaterialTheme.typography.caption) + } else { + activeRows.forEach { row -> + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(row.memberName, Modifier.weight(1f), style = MaterialTheme.typography.caption) + Text(row.statusText, style = MaterialTheme.typography.caption, modifier = Modifier.padding(horizontal = 4.dp)) + if (canWrite) { + when { + row.wants && !row.registered && !row.participated -> { + SmallStatusBtn(clubId, tournamentId, row.key, "register", tr("officialTournaments.registeredShort", "Angemeldet"), participationMap, scope, dependencies, onShowInfo, tr) + } + row.registered && !row.participated -> { + SmallStatusBtn(clubId, tournamentId, row.key, "participate", tr("officialTournaments.playedShort", "Teilgenommen"), participationMap, scope, dependencies, onShowInfo, tr) + } + row.participated -> { + SmallStatusBtn(clubId, tournamentId, row.key, "reset", tr("officialTournaments.resetShort", "Zurücksetzen"), participationMap, scope, dependencies, onShowInfo, tr) + } + } + } + } + if (row.participated && canWrite) { + CompetitionPlacementRow( + clubId = clubId, + tournamentId = tournamentId, + rowKey = row.key, + initialPlacement = row.placement, + wants = row.wants, + registered = row.registered, + participated = row.participated, + participationMap = participationMap, + dependencies = dependencies, + scope = scope, + onShowInfo = onShowInfo, + tr = tr, + ) + } + } + } + } + } + } + Divider() + } +} + +@Composable +private fun CompetitionPlacementRow( + clubId: Int, + tournamentId: Int, + rowKey: String, + initialPlacement: String?, + wants: Boolean, + registered: Boolean, + participated: Boolean, + participationMap: MutableMap, + dependencies: AppDependencies, + scope: kotlinx.coroutines.CoroutineScope, + onShowInfo: (String, String) -> Unit, + tr: (String, String) -> String, +) { + var placementDraft by remember(rowKey, initialPlacement) { mutableStateOf(initialPlacement.orEmpty()) } + Row(Modifier.fillMaxWidth().padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = placementDraft, + onValueChange = { placementDraft = it }, + modifier = Modifier.width(88.dp), + singleLine = true, + label = { Text(tr("officialTournaments.placement", "Platzierung"), style = MaterialTheme.typography.caption) }, + ) + TextButton( + onClick = { + scope.launch { + val cid = rowKey.substringBeforeLast('-').toInt() + val mid = rowKey.substringAfterLast('-').toInt() + runCatching { + withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.upsertParticipation( + clubId, + tournamentId, + OfficialUpsertParticipationBody( + competitionId = cid, + memberId = mid, + wants = wants, + registered = registered, + participated = participated, + placement = placementDraft.trim().ifBlank { null }, + ), + ) + } + val cur = getParticipation(participationMap, cid, mid) + participationMap[rowKey] = cur.copy(placement = placementDraft.trim().ifBlank { null }) + }.onFailure { onShowInfo(tr("common.error", "Fehler"), it.message ?: "") } + } + }, + ) { Text("OK") } + } +} + +@Composable +private fun SmallStatusBtn( + clubId: Int, + tournamentId: Int, + rowKey: String, + action: String, + label: String, + participationMap: MutableMap, + scope: kotlinx.coroutines.CoroutineScope, + dependencies: AppDependencies, + onShowInfo: (String, String) -> Unit, + tr: (String, String) -> String, +) { + TextButton( + onClick = { + scope.launch { + val cid = rowKey.substringBeforeLast('-').toInt() + val mid = rowKey.substringAfterLast('-').toInt() + runCatching { + val res = withContext(Dispatchers.IO) { + dependencies.officialTournamentsApi.updateParticipantStatus( + clubId, + tournamentId, + OfficialParticipantStatusBody(competitionId = cid, memberId = mid, action = action), + ) + } + val st = res.status + if (res.success && st != null) { + participationMap[rowKey] = ParticipationState(st.wants, st.registered, st.participated, st.placement) + } + }.onFailure { onShowInfo(tr("common.error", "Fehler"), it.message ?: "") } + } + }, + ) { Text(label, style = MaterialTheme.typography.caption) } +} + +@Composable +private fun ResultsTabContent( + clubId: Int, + tournamentId: Int, + parsed: OfficialParsedTournamentEnvelopeDto, + participationMap: MutableMap, + canWrite: Boolean, + dependencies: AppDependencies, + tr: (String, String) -> String, + memberNameById: (Int) -> String, + scope: kotlinx.coroutines.CoroutineScope, + onReload: () -> Unit, + onShowInfo: (String, String) -> Unit, +) { + val rows = remember(parsed, participationMap.toMap()) { + resultsRows(parsed, participationMap.toMap(), memberNameById) + } + val groups = remember(rows) { resultsGroups(rows) } + Text(tr("officialTournaments.results", "Ergebnisse"), fontWeight = FontWeight.SemiBold) + if (groups.isEmpty()) { + Text(tr("officialTournaments.noResults", "Keine Ergebnisse vorhanden."), style = MaterialTheme.typography.caption) + } else { + groups.forEach { g -> + Text(g.memberName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 8.dp)) + g.items.forEach { item -> + ParticipantItemRow( + clubId = clubId, + tournamentId = tournamentId, + item = item, + groupMemberId = g.memberId, + canWrite = canWrite, + dependencies = dependencies, + participationMap = participationMap, + scope = scope, + onReload = onReload, + onShowInfo = onShowInfo, + tr = tr, + onMarkInterested = {}, + ) + Divider() + } + } + } +} + +@Composable +private fun MemberSelectionDialog( + activeMembers: List, + selectedIds: Set, + onSelectedIdsChange: (Set) -> Unit, + dialogMemberId: Int?, + onDialogMemberIdChange: (Int?) -> Unit, + parsed: OfficialParsedTournamentEnvelopeDto?, + recommendations: Map>, + onRecommendationsChange: (Map>) -> Unit, + onDismiss: () -> Unit, + tr: (String, String) -> String, +) { + Dialog(onDismissRequest = onDismiss) { + Surface(shape = MaterialTheme.shapes.medium, elevation = 8.dp) { + Column( + Modifier + .padding(16.dp) + .heightIn(max = 520.dp) + .verticalScroll(rememberScrollState()), + ) { + Text(tr("officialTournaments.selectMembers", "Mitglieder auswählen"), fontWeight = FontWeight.SemiBold) + Row { + TextButton(onClick = { onSelectedIdsChange(activeMembers.map { it.id }.toSet()) }) { + Text(tr("officialTournaments.selectAll", "Alle")) + } + TextButton(onClick = { onSelectedIdsChange(emptySet()) }) { Text(tr("officialTournaments.deselectAll", "Keine")) } + } + activeMembers.forEach { m -> + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = selectedIds.contains(m.id), + onCheckedChange = { + onDialogMemberIdChange(m.id) + if (it) onSelectedIdsChange(selectedIds + m.id) else onSelectedIdsChange(selectedIds - m.id) + }, + ) + Text("${m.firstName} ${m.lastName}", modifier = Modifier.clickable { onDialogMemberIdChange(m.id) }) + } + if (dialogMemberId == m.id && parsed != null) { + val comps = remember(parsed, m) { competitionsForMember(parsed, m) } + comps.forEach { cr -> + val rec = recommendations[m.id]?.contains(cr.key) == true + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 40.dp)) { + Checkbox( + checked = rec, + onCheckedChange = { checked -> + val set = recommendations[m.id].orEmpty().toMutableSet() + if (checked) set.add(cr.key) else set.remove(cr.key) + onRecommendationsChange(recommendations + (m.id to set)) + }, + ) + Column { + Text(cr.name, style = MaterialTheme.typography.caption) + Text("${cr.date} ${cr.time} · ${cr.entryFee}", style = MaterialTheme.typography.caption) + } + } + } + } + } + TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { + Text(tr("common.close", "Schließen")) + } + } + } + } +} + +/** + * Einstieg aus dem Hauptmenü (Nav-Rail / untere Leiste): offizielle Turniere & Turnierteilnahmen. + */ +@Composable +internal fun OfficialTournamentsMenuRoute( + dependencies: AppDependencies, + onNavigateBack: () -> Unit, +) { + val languageCode = LocalLanguageCode.current + fun translate(key: String, fallback: String) = MobileStrings.get(languageCode, key, fallback) + val clubState by dependencies.clubManager.state.collectAsState() + val clubId = clubState.currentClubId ?: return + val perms = clubState.currentPermissions + if (perms?.canReadTournaments() != true) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + ) { + Text(translate("mobile.noTournamentAccess", "Keine Berechtigung für Turniere.")) + TextButton(onClick = onNavigateBack) { Text(translate("mobile.back", "Zurück")) } + } + return + } + OfficialTournamentsWorkspaceScreen( + clubId = clubId, + canWrite = perms.canWriteTournaments(), + dependencies = dependencies, + tr = ::translate, + onClose = onNavigateBack, + ) +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamManagementScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamManagementScreen.kt new file mode 100644 index 00000000..542a4920 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TeamManagementScreen.kt @@ -0,0 +1,486 @@ +package de.tt_tagebuch.app.ui + +import androidx.compose.foundation.layout.Arrangement +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.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.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.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.ScheduleLogic +import de.tt_tagebuch.shared.api.models.ClubLeagueOptionDto +import de.tt_tagebuch.shared.api.models.ClubTeamCreateBody +import de.tt_tagebuch.shared.api.models.ClubTeamDto +import de.tt_tagebuch.shared.api.models.ClubTeamUpdateBody +import de.tt_tagebuch.shared.api.models.SeasonDto +import de.tt_tagebuch.shared.api.models.canReadTeams +import de.tt_tagebuch.shared.api.models.canWriteTeams +import de.tt_tagebuch.shared.i18n.MobileStrings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val TeamPad = 20.dp +private val TeamAgeOptions = listOf("adult", "J19", "J17", "J15", "J13", "J11") +private val TeamGenderOptions = listOf("open", "female") + +@Composable +internal fun TeamManagementScreen( + 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 clubId = clubState.currentClubId ?: return + val perms = clubState.currentPermissions + val canRead = perms?.canReadTeams() == true + val canWrite = perms?.canWriteTeams() == true + val scope = rememberCoroutineScope() + + var seasons by remember { mutableStateOf>(emptyList()) } + var selectedSeasonId by remember { mutableStateOf(null) } + var teams by remember { mutableStateOf>(emptyList()) } + var leagues by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var search by remember { mutableStateOf("") } + + var showForm by remember { mutableStateOf(false) } + var formIsNew by remember { mutableStateOf(true) } + var formTeamId by remember { mutableStateOf(null) } + var formName by remember { mutableStateOf("") } + var formLeagueId by remember { mutableStateOf(null) } + var formPlannedLeague by remember { mutableStateOf("") } + var formGender by remember { mutableStateOf("open") } + var formAge by remember { mutableStateOf("adult") } + var formBusy by remember { mutableStateOf(false) } + + var seasonMenu by remember { mutableStateOf(false) } + var leagueMenu by remember { mutableStateOf(false) } + var genderMenu by remember { mutableStateOf(false) } + var ageMenu by remember { mutableStateOf(false) } + + var deleteTarget by remember { mutableStateOf(null) } + var infoMessage by remember { mutableStateOf(null) } + + fun resetFormForNew() { + formIsNew = true + formTeamId = null + formName = "" + formLeagueId = null + formPlannedLeague = "" + formGender = "open" + formAge = "adult" + } + + fun openEdit(team: ClubTeamDto) { + formIsNew = false + formTeamId = team.id + formName = team.name + formLeagueId = team.leagueId + formPlannedLeague = team.plannedLeagueName.orEmpty() + formGender = team.teamGender?.takeIf { it.isNotBlank() } ?: "open" + formAge = team.teamAgeGroup?.takeIf { it.isNotBlank() } ?: "adult" + showForm = true + } + + suspend fun reloadData() { + loading = true + error = null + runCatching { + val s = withContext(Dispatchers.IO) { dependencies.seasonsApi.listSeasons() } + seasons = s.sortedByDescending { it.id } + if (selectedSeasonId == null) { + val cur = withContext(Dispatchers.IO) { dependencies.seasonsApi.getCurrentSeason() } + selectedSeasonId = cur.id + } + val sid = selectedSeasonId + val t = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listClubTeams(clubId, sid) } + val lg = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listLeagues(clubId, sid) } + teams = ScheduleLogic.sortClubTeams(t) + leagues = lg.sortedBy { it.name.lowercase() } + }.onFailure { error = it.message ?: tr("mobile.teamLoadError", "Daten konnten nicht geladen werden.") } + loading = false + } + + LaunchedEffect(clubId) { + selectedSeasonId = null + seasons = emptyList() + reloadData() + } + + LaunchedEffect(clubId, selectedSeasonId) { + if (selectedSeasonId == null) return@LaunchedEffect + loading = true + error = null + runCatching { + val sid = selectedSeasonId + val t = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listClubTeams(clubId, sid) } + val lg = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listLeagues(clubId, sid) } + teams = ScheduleLogic.sortClubTeams(t) + leagues = lg.sortedBy { it.name.lowercase() } + }.onFailure { error = it.message ?: tr("mobile.teamLoadError", "Daten konnten nicht geladen werden.") } + loading = false + } + + val filtered = remember(teams, search) { + val q = search.trim().lowercase() + if (q.isEmpty()) teams + else teams.filter { t -> + listOf(t.name, t.league?.name, t.season?.season, t.plannedLeagueName) + .filterNotNull() + .any { it.lowercase().contains(q) } + } + } + + Column(Modifier.fillMaxSize().padding(TeamPad)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + Text( + tr("navigation.teamManagement", "Team-Verwaltung"), + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + if (canWrite) { + Button( + onClick = { + resetFormForNew() + showForm = true + }, + enabled = !loading && selectedSeasonId != null, + ) { + Text(tr("mobile.teamNew", "Neue Mannschaft")) + } + } + } + + if (!canRead) { + Text(tr("mobile.noTeamAccess", "Keine Berechtigung für die Team-Verwaltung.")) + return@Column + } + + Text( + tr("mobile.teamsIntro", "Mannschaften pro Saison verwalten (Name, Liga, geplante Liga, Geschlechtsklasse, Altersklasse)."), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f), + ) + Spacer(Modifier.height(8.dp)) + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(tr("mobile.season", "Saison") + ":", style = MaterialTheme.typography.body2) + Box { + OutlinedButton(onClick = { seasonMenu = true }, enabled = seasons.isNotEmpty()) { + Text( + seasons.find { it.id == selectedSeasonId }?.season + ?: tr("mobile.seasonPick", "Saison wählen"), + ) + } + DropdownMenu(expanded = seasonMenu, onDismissRequest = { seasonMenu = false }) { + seasons.forEach { s -> + DropdownMenuItem( + onClick = { + selectedSeasonId = s.id + seasonMenu = false + }, + ) { Text(s.season) } + } + } + } + } + + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = search, + onValueChange = { search = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(tr("mobile.teamSearch", "Mannschaft suchen")) }, + ) + + error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colors.error) + } + + if (loading && teams.isEmpty()) { + CircularProgressIndicator(Modifier.padding(top = 24.dp).align(Alignment.CenterHorizontally)) + return@Column + } + + if (!loading && teams.isEmpty()) { + Spacer(Modifier.height(16.dp)) + Text(tr("mobile.noTeams", "Keine Mannschaften für diese Saison.")) + return@Column + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(filtered, key = { it.id }) { team -> + Card(elevation = 1.dp, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text(team.name.ifBlank { "#${team.id}" }, fontWeight = FontWeight.SemiBold) + val lg = team.league?.name?.takeIf { it.isNotBlank() } + ?: tr("mobile.noLeague", "Keine Liga") + Text(lg, style = MaterialTheme.typography.caption) + team.season?.season?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.caption) + } + team.plannedLeagueName?.takeIf { it.isNotBlank() }?.let { pl -> + Text( + tr("mobile.plannedLeagueShort", "Geplant: ") + pl, + style = MaterialTheme.typography.caption, + ) + } + val g = team.teamGender ?: "open" + val a = team.teamAgeGroup ?: "adult" + Text( + "${tr("mobile.teamGenderShort", "Geschlecht")}: ${labelGender(tr, g)} · ${tr("mobile.teamAgeShort", "AK")}: ${labelAge(tr, a)}", + style = MaterialTheme.typography.caption, + ) + team.myTischtennisTeamId?.takeIf { it.isNotBlank() }?.let { mid -> + Text("myTischtennis: $mid", style = MaterialTheme.typography.caption) + } + if (canWrite) { + Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { openEdit(team) }) { + Text(tr("common.edit", "Bearbeiten")) + } + TextButton(onClick = { deleteTarget = team }) { + Text(tr("common.delete", "Löschen"), color = MaterialTheme.colors.error) + } + } + } + } + } + } + } + } + + if (showForm && selectedSeasonId != null) { + val sid = selectedSeasonId!! + AlertDialog( + onDismissRequest = { if (!formBusy) showForm = false }, + title = { + Text( + if (formIsNew) tr("mobile.teamNew", "Neue Mannschaft") + else tr("mobile.teamEdit", "Mannschaft bearbeiten"), + ) + }, + text = { + Column( + Modifier + .heightIn(max = 420.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = formName, + onValueChange = { formName = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(tr("mobile.teamName", "Name")) }, + ) + Text(tr("mobile.league", "Liga"), style = MaterialTheme.typography.caption) + Box { + OutlinedButton(onClick = { leagueMenu = true }, modifier = Modifier.fillMaxWidth()) { + Text( + formLeagueId?.let { id -> leagues.find { it.id == id }?.name } + ?: tr("mobile.noLeague", "Keine Liga"), + ) + } + DropdownMenu(expanded = leagueMenu, onDismissRequest = { leagueMenu = false }) { + DropdownMenuItem( + onClick = { + formLeagueId = null + leagueMenu = false + }, + ) { Text(tr("mobile.noLeague", "Keine Liga")) } + leagues.forEach { lg -> + DropdownMenuItem( + onClick = { + formLeagueId = lg.id + leagueMenu = false + }, + ) { Text(lg.name) } + } + } + } + OutlinedTextField( + value = formPlannedLeague, + onValueChange = { formPlannedLeague = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(tr("mobile.plannedLeague", "Geplante Liga")) }, + ) + Text(tr("mobile.teamGenderShort", "Geschlecht"), style = MaterialTheme.typography.caption) + Box { + OutlinedButton(onClick = { genderMenu = true }, modifier = Modifier.fillMaxWidth()) { + Text(labelGender(tr, formGender)) + } + DropdownMenu(expanded = genderMenu, onDismissRequest = { genderMenu = false }) { + TeamGenderOptions.forEach { g -> + DropdownMenuItem( + onClick = { + formGender = g + genderMenu = false + }, + ) { Text(labelGender(tr, g)) } + } + } + } + Text(tr("mobile.teamAgeShort", "Altersklasse"), style = MaterialTheme.typography.caption) + Box { + OutlinedButton(onClick = { ageMenu = true }, modifier = Modifier.fillMaxWidth()) { + Text(labelAge(tr, formAge)) + } + DropdownMenu(expanded = ageMenu, onDismissRequest = { ageMenu = false }) { + TeamAgeOptions.forEach { a -> + DropdownMenuItem( + onClick = { + formAge = a + ageMenu = false + }, + ) { Text(labelAge(tr, a)) } + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = !formBusy && formName.isNotBlank(), + onClick = { + scope.launch { + formBusy = true + runCatching { + if (formIsNew) { + withContext(Dispatchers.IO) { + dependencies.clubTeamsApi.createClubTeam( + clubId, + ClubTeamCreateBody( + name = formName.trim(), + leagueId = formLeagueId, + seasonId = sid, + teamGender = formGender, + teamAgeGroup = formAge, + plannedLeagueName = formPlannedLeague.trim().ifBlank { null }, + ), + ) + } + } else { + val id = formTeamId ?: return@launch + withContext(Dispatchers.IO) { + dependencies.clubTeamsApi.updateClubTeam( + id, + ClubTeamUpdateBody( + name = formName.trim(), + leagueId = formLeagueId, + seasonId = sid, + teamGender = formGender, + teamAgeGroup = formAge, + plannedLeagueName = formPlannedLeague.trim().ifBlank { null }, + ), + ) + } + } + reloadData() + dependencies.scheduleManager.loadClubTeams(clubId) + showForm = false + }.onFailure { + infoMessage = it.message ?: tr("common.error", "Fehler") + } + formBusy = false + } + }, + ) { Text(tr("common.save", "Speichern")) } + }, + dismissButton = { + TextButton(onClick = { if (!formBusy) showForm = false }) { + Text(tr("common.cancel", "Abbrechen")) + } + }, + ) + } + + deleteTarget?.let { target -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text(tr("mobile.teamDeleteTitle", "Mannschaft löschen?")) }, + text = { Text(target.name.ifBlank { "#${target.id}" }) }, + confirmButton = { + TextButton( + onClick = { + val id = target.id + deleteTarget = null + scope.launch { + runCatching { + withContext(Dispatchers.IO) { dependencies.clubTeamsApi.deleteClubTeam(id) } + reloadData() + dependencies.scheduleManager.loadClubTeams(clubId) + }.onFailure { infoMessage = it.message } + } + }, + ) { Text(tr("common.delete", "Löschen")) } + }, + dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text(tr("common.cancel", "Abbrechen")) } }, + ) + } + + infoMessage?.let { msg -> + AlertDialog( + onDismissRequest = { infoMessage = null }, + title = { Text(tr("common.error", "Fehler")) }, + text = { Text(msg) }, + confirmButton = { TextButton(onClick = { infoMessage = null }) { Text(tr("common.ok", "OK")) } }, + ) + } +} + +private fun labelGender(tr: (String, String) -> String, code: String): String = + when (code) { + "female" -> tr("mobile.teamGenderFemale", "Weiblich") + else -> tr("mobile.teamGenderOpen", "Offen") + } + +private fun labelAge(tr: (String, String) -> String, code: String): String = + if (code == "adult") tr("mobile.teamAgeAdult", "Erwachsene") else code diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt index a61a3124..82e5a58d 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,9 +18,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton import androidx.compose.material.OutlinedTextField import androidx.compose.material.Surface import androidx.compose.material.Text @@ -38,11 +41,18 @@ import androidx.compose.ui.draw.clip 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.AddMiniChampionshipBody +import de.tt_tagebuch.shared.api.models.AddStandardTournamentBody import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto import de.tt_tagebuch.shared.api.models.OfficialParticipationEntryDto import de.tt_tagebuch.shared.api.models.canReadTournaments +import de.tt_tagebuch.shared.api.models.canWriteTournaments import de.tt_tagebuch.shared.i18n.MobileStrings import de.tt_tagebuch.shared.state.ClubTournamentDisplayFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Calendar private data class ParticipationFlatRow( val tournamentId: String?, @@ -53,6 +63,14 @@ private data class ParticipationFlatRow( private val TournamentsPad = 20.dp private val TournamentsTouchMin = 48.dp +private sealed class TournamentsMainRoute { + data object Browse : TournamentsMainRoute() + data class Editor(val tournamentId: Int) : TournamentsMainRoute() + data object CreateStandard : TournamentsMainRoute() + data object CreateMini : TournamentsMainRoute() + data object OfficialWorkspace : TournamentsMainRoute() +} + @Composable internal fun TournamentsScreen(dependencies: AppDependencies) { val languageCode = LocalLanguageCode.current @@ -64,6 +82,7 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { val officialState by dependencies.officialTournamentsReadManager.state.collectAsState() var searchQuery by rememberSaveable { mutableStateOf("") } var showInternalStats by remember { mutableStateOf(false) } + var mainRoute by remember { mutableStateOf(TournamentsMainRoute.Browse) } if (perms?.canReadTournaments() != true) { Column( @@ -80,11 +99,6 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { 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) } @@ -133,7 +147,9 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { ) } - LazyColumn( + when (val route = mainRoute) { + TournamentsMainRoute.Browse -> { + LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = TournamentsPad, vertical = 16.dp), @@ -153,6 +169,15 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { ) } + item { + OutlinedButton( + onClick = { mainRoute = TournamentsMainRoute.OfficialWorkspace }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(tr("officialTournaments.openWorkspace", "Offizielle Turniere & Teilnahmen …")) + } + } + item { Row( modifier = Modifier @@ -225,6 +250,20 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { ) } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + if (internalState.filter == ClubTournamentDisplayFilter.Mini) { + OutlinedButton(onClick = { mainRoute = TournamentsMainRoute.CreateMini }) { + Text(tr("tournaments.miniChampionships", "Minimeisterschaft") + " +") + } + } else { + OutlinedButton(onClick = { mainRoute = TournamentsMainRoute.CreateStandard }) { + Text(tr("tournaments.newTournament", "Neues Turnier")) + } + } + } + } + if (internalState.isLoadingList) { item { CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp)) } } else { @@ -261,52 +300,16 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { filteredInternal, key = { _, t -> t.id.toString() }, ) { _, t -> - val selected = internalState.selectedId == t.id InternalTournamentListCard( tournament = t, - selected = selected, + selected = false, unknownDateLabel = tr("tournaments.unknownDate", "Datum unbekannt"), - onClick = { - dependencies.clubInternalTournamentsManager.selectTournament( - if (selected) null else t.id, - ) - }, + onClick = { mainRoute = TournamentsMainRoute.Editor(t.id) }, ) } } } - 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) @@ -365,11 +368,242 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { item { Spacer(modifier = Modifier.height(20.dp)) Text( - tr("mobile.tournamentsWebHintFooter", "Turniere anlegen, bearbeiten oder Meldelisten verwalten – optional im Webbrowser."), + tr("mobile.tournamentsWebHintFooter", "Offizielle Meldelisten und Teilnahmen unten; Vereins-Turniere oben in der App bearbeitbar."), style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.62f), ) } + } + } + is TournamentsMainRoute.Editor -> InternalTournamentEditorScreen( + clubId = clubId, + tournamentId = route.tournamentId, + dependencies = dependencies, + onClose = { + mainRoute = TournamentsMainRoute.Browse + dependencies.applicationScope.launch { + dependencies.clubInternalTournamentsManager.loadList(clubId) + } + }, + ) + TournamentsMainRoute.CreateStandard -> TournamentCreateStandardScreen( + clubId = clubId, + allowsExternal = internalState.filter == ClubTournamentDisplayFilter.External, + dependencies = dependencies, + tr = ::tr, + onBack = { mainRoute = TournamentsMainRoute.Browse }, + onCreated = { newId: Int -> + mainRoute = TournamentsMainRoute.Editor(newId) + dependencies.applicationScope.launch { + dependencies.clubInternalTournamentsManager.loadList(clubId) + } + }, + ) + TournamentsMainRoute.CreateMini -> TournamentCreateMiniScreen( + clubId = clubId, + dependencies = dependencies, + tr = ::tr, + onBack = { mainRoute = TournamentsMainRoute.Browse }, + onCreated = { newId: Int -> + mainRoute = TournamentsMainRoute.Editor(newId) + dependencies.applicationScope.launch { + dependencies.clubInternalTournamentsManager.loadList(clubId) + } + }, + ) + TournamentsMainRoute.OfficialWorkspace -> OfficialTournamentsWorkspaceScreen( + clubId = clubId, + canWrite = perms?.canWriteTournaments() == true, + dependencies = dependencies, + tr = ::tr, + onClose = { + mainRoute = TournamentsMainRoute.Browse + dependencies.applicationScope.launch { + dependencies.officialTournamentsReadManager.load(clubId) + } + }, + ) + } +} + +@Composable +private fun TournamentCreateStandardScreen( + clubId: Int, + allowsExternal: Boolean, + dependencies: AppDependencies, + tr: (String, String) -> String, + onBack: () -> Unit, + onCreated: (Int) -> Unit, +) { + var name by remember { mutableStateOf("") } + var date by rememberSaveable { mutableStateOf("") } + var ws by remember { mutableStateOf("3") } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + val scroll = rememberScrollState() + Column( + Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(horizontal = TournamentsPad, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton(onClick = onBack) { Text(tr("mobile.back", "Zurück")) } + Text( + tr("tournaments.newTournament", "Neues Turnier"), + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.SemiBold, + ) + error?.let { Text(it, color = MaterialTheme.colors.error) } + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(tr("tournaments.tournamentName", "Turniername")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = date, + onValueChange = { date = it }, + label = { Text(tr("tournaments.date", "Datum (YYYY-MM-DD)")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = ws, + onValueChange = { ws = it.filter { ch -> ch.isDigit() }.take(2) }, + label = { Text(tr("tournaments.winningSets", "Gewinnsätze")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Button( + onClick = { + if (date.isBlank()) { + error = tr("tournaments.pleaseEnterDate", "Bitte Datum eingeben") + return@Button + } + busy = true + error = null + dependencies.applicationScope.launch { + runCatching { + val res = withContext(Dispatchers.IO) { + dependencies.tournamentsApi.addStandardTournament( + AddStandardTournamentBody( + clubId = clubId, + tournamentName = name.trim().ifBlank { date.trim() }, + date = date.trim(), + winningSets = ws.toIntOrNull() ?: 3, + allowsExternal = allowsExternal, + isDoublesTournament = false, + ), + ) + } + onCreated(res.id) + }.onFailure { error = it.message } + busy = false + } + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (busy) "…" else tr("common.create", "Erstellen")) + } + } +} + +@Composable +private fun TournamentCreateMiniScreen( + clubId: Int, + dependencies: AppDependencies, + tr: (String, String) -> String, + onBack: () -> Unit, + onCreated: (Int) -> Unit, +) { + var ort by remember { mutableStateOf("") } + var date by rememberSaveable { mutableStateOf("") } + var year by remember { mutableStateOf(Calendar.getInstance().get(Calendar.YEAR).toString()) } + var ws by remember { mutableStateOf("1") } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + val scroll = rememberScrollState() + Column( + Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(horizontal = TournamentsPad, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton(onClick = onBack) { Text(tr("mobile.back", "Zurück")) } + Text( + tr("tournaments.miniChampionships", "Minimeisterschaft"), + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.SemiBold, + ) + error?.let { Text(it, color = MaterialTheme.colors.error) } + OutlinedTextField( + value = ort, + onValueChange = { ort = it }, + label = { Text(tr("tournaments.miniChampionshipLocation", "Ort")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = date, + onValueChange = { date = it }, + label = { Text(tr("tournaments.date", "Datum")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = year, + onValueChange = { year = it.filter { ch -> ch.isDigit() }.take(4) }, + label = { Text(tr("tournaments.miniChampionshipYear", "Jahr")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = ws, + onValueChange = { ws = it.filter { ch -> ch.isDigit() }.take(1) }, + label = { Text(tr("tournaments.winningSets", "Gewinnsätze")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Button( + onClick = { + if (ort.isBlank() || date.isBlank()) { + error = tr("tournaments.enterMiniLocation", "Ort und Datum eingeben") + return@Button + } + val y = year.toIntOrNull() ?: 0 + if (y < 2000 || y > 2100) { + error = tr("tournaments.miniChampionshipYearHint", "Jahr ungültig") + return@Button + } + busy = true + error = null + dependencies.applicationScope.launch { + runCatching { + val res = withContext(Dispatchers.IO) { + dependencies.tournamentsApi.addMiniChampionship( + AddMiniChampionshipBody( + clubId = clubId, + ort = ort.trim(), + date = date.trim(), + year = y, + winningSets = ws.toIntOrNull() ?: 1, + ), + ) + } + onCreated(res.id) + }.onFailure { error = it.message } + busy = false + } + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (busy) "…" else tr("common.create", "Erstellen")) + } } } diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/util/OfficialTournamentEligibility.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/util/OfficialTournamentEligibility.kt new file mode 100644 index 00000000..7ab27980 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/util/OfficialTournamentEligibility.kt @@ -0,0 +1,160 @@ +package de.tt_tagebuch.app.util + +import de.tt_tagebuch.shared.api.models.Member +import de.tt_tagebuch.shared.api.models.OfficialParsedCompetitionDto +import de.tt_tagebuch.shared.api.models.OfficialParsedTournamentEnvelopeDto +import java.text.Collator +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.GregorianCalendar +import java.util.Locale + +/** + * Entspricht der Logik in [frontend/src/views/OfficialTournaments.vue] (isEligibleForCompetition). + */ +object OfficialTournamentEligibility { + + private val collator: Collator = Collator.getInstance(Locale.GERMAN).apply { strength = Collator.PRIMARY } + + fun compareMembers(a: Member, b: Member): Int { + val c = collator.compare(a.firstName, b.firstName) + if (c != 0) return c + return collator.compare(a.lastName, b.lastName) + } + + fun eligibleActiveMembers( + parsed: OfficialParsedTournamentEnvelopeDto?, + competition: OfficialParsedCompetitionDto, + members: List, + ): List { + return members + .filter { it.active } + .filter { isEligibleForCompetition(it, competition, parsed) } + .sortedWith(::compareMembers) + } + + fun isEligibleForCompetition( + member: Member, + c: OfficialParsedCompetitionDto, + parsed: OfficialParsedTournamentEnvelopeDto?, + ): Boolean { + val rule = getGenderRule(c) + val g = (member.gender ?: "unknown").lowercase(Locale.ROOT) + if (rule == "female" && g != "female") return false + if (rule == "male" && g != "male") return false + if (!isEligibleByAge(member, c, parsed)) return false + return true + } + + private fun getGenderRule(c: OfficialParsedCompetitionDto): String { + val txt = "${c.ageClassCompetition ?: c.altersklasseWettbewerb ?: ""} ${c.openTo ?: c.offenFuer ?: ""}" + .lowercase(Locale.ROOT) + if (Regex("(mädchen|weiblich|\\bw\\b)").containsMatchIn(txt)) return "female" + if (Regex("(jungen|männlich|\\bm\\b)").containsMatchIn(txt)) return "male" + if ("jugend" in txt) return "both" + return "both" + } + + /** Entspricht [OfficialTournaments.vue] `isEligibleByAge` (Reihenfolge: Jahrgang → Stichtag → U/Jugend/AK). */ + private fun isEligibleByAge(member: Member, c: OfficialParsedCompetitionDto, parsed: OfficialParsedTournamentEnvelopeDto?): Boolean { + val bd = parseDateFlexible(member.birthDate ?: "") ?: return false + + val birthYearRule = getBirthYearFromPerformanceClass(c) + if (birthYearRule != null) { + val memberYear = bd.get(Calendar.YEAR) + if (birthYearRule.exact != null) return memberYear == birthYearRule.exact + if (birthYearRule.minYear != null) return memberYear >= birthYearRule.minYear + } + + val cutoff = getCutoffDate(c) + if (cutoff != null) { + if (bd.timeInMillis <= cutoff.timeInMillis) return false + } + + val ageLimit = getAgeLimitFromText("${c.ageClassCompetition ?: c.altersklasseWettbewerb ?: ""}") + if (ageLimit != null) { + val ref = getReferenceDate(c, parsed) ?: return false + val age = calculateAgeOnDate(bd, ref) ?: return false + return if (ageLimit.exclusive) age < ageLimit.limit else age <= ageLimit.limit + } + + return true + } + + data class AgeLimit(val limit: Int, val exclusive: Boolean) + data class BirthYearRule(val exact: Int? = null, val minYear: Int? = null) + + private fun getAgeLimitFromText(text: String?): AgeLimit? { + if (text.isNullOrBlank()) return null + val t = text + Regex("(?:jugend|mädchen|maedchen)\\s+(\\d{1,2})\\b", RegexOption.IGNORE_CASE).find(t)?.let { + return AgeLimit(it.groupValues[1].toInt(), exclusive = false) + } + Regex("\\bU\\s*(\\d{1,2})\\b", RegexOption.IGNORE_CASE).find(t)?.let { + return AgeLimit(it.groupValues[1].toInt(), exclusive = true) + } + Regex("\\bAK\\s*(\\d{1,2})\\b", RegexOption.IGNORE_CASE).find(t)?.let { m -> + if (!Regex("\\(\\s*AK\\s*\\d+\\s*\\)", RegexOption.IGNORE_CASE).containsMatchIn(t)) { + return AgeLimit(m.groupValues[1].toInt(), exclusive = true) + } + } + return null + } + + private fun getBirthYearFromPerformanceClass(c: OfficialParsedCompetitionDto): BirthYearRule? { + val pc = "${c.performanceClass ?: c.leistungsklasse ?: ""}" + Regex("jahrgang\\s+(\\d{4})\\s+und\\s+jünger", RegexOption.IGNORE_CASE).find(pc)?.let { + return BirthYearRule(minYear = it.groupValues[1].toInt()) + } + Regex("jahrgang\\s+(\\d{4})\\b", RegexOption.IGNORE_CASE).find(pc)?.let { + return BirthYearRule(exact = it.groupValues[1].toInt()) + } + return null + } + + private fun getCutoffDate(c: OfficialParsedCompetitionDto): Calendar? { + var valStr = (c.cutoffDate ?: c.stichtag ?: "").trim() + if (valStr.isEmpty()) { + val pc = "${c.performanceClass ?: c.leistungsklasse ?: ""}" + Regex("stichtag\\s*:?\\s*([0-3]?\\d\\.[01]?\\d\\.\\d{4})", RegexOption.IGNORE_CASE).find(pc)?.let { + valStr = it.groupValues[1].trim() + } + } + return parseDateFlexible(valStr) + } + + fun getReferenceDate(c: OfficialParsedCompetitionDto, parsed: OfficialParsedTournamentEnvelopeDto?): Calendar? { + val fromStart = Regex("(\\d{1,2}\\.\\d{1,2}\\.\\d{4})").find("${c.startTime ?: c.startzeit ?: ""}") + if (fromStart != null) return parseDateFlexible(fromStart.value) + return parseDateFlexible(parsed?.parsedData?.termin ?: "") + } + + private fun calculateAgeOnDate(birth: Calendar, ref: Calendar): Int? { + var age = ref.get(Calendar.YEAR) - birth.get(Calendar.YEAR) + val m = ref.get(Calendar.MONTH) - birth.get(Calendar.MONTH) + if (m < 0 || (m == 0 && ref.get(Calendar.DAY_OF_MONTH) < birth.get(Calendar.DAY_OF_MONTH))) age-- + return age + } + + fun parseDateFlexible(s: String?): Calendar? { + if (s.isNullOrBlank()) return null + val t = s.trim() + Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})$").find(t)?.let { + val d = it.groupValues[1].toInt() + val m = it.groupValues[2].toInt() + val y = it.groupValues[3].toInt() + return GregorianCalendar(y, m - 1, d) + } + Regex("^(\\d{4})-(\\d{2})-(\\d{2})$").find(t)?.let { + val y = it.groupValues[1].toInt() + val m = it.groupValues[2].toInt() + val d = it.groupValues[3].toInt() + return GregorianCalendar(y, m - 1, d) + } + return runCatching { + val ms = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN).parse(t)?.time + ?: SimpleDateFormat("yyyy-MM-dd", Locale.GERMAN).parse(t)?.time + if (ms == null) null else GregorianCalendar.getInstance().apply { timeInMillis = ms } + }.getOrNull() + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt index 8f4e470e..f23afb23 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt @@ -1,10 +1,17 @@ package de.tt_tagebuch.shared.api import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.ClubLeagueOptionDto +import de.tt_tagebuch.shared.api.models.ClubTeamCreateBody import de.tt_tagebuch.shared.api.models.ClubTeamDto +import de.tt_tagebuch.shared.api.models.ClubTeamUpdateBody import io.ktor.client.call.body +import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody class ClubTeamsApi( private val client: AuthedHttpClient, @@ -14,4 +21,30 @@ class ClubTeamsApi( seasonId?.let { parameter("seasonid", it) } }.body() } + + suspend fun listLeagues(clubId: Int, seasonId: Int? = null): List { + return client.http.get("/api/club-teams/leagues/$clubId") { + seasonId?.let { parameter("seasonid", it) } + }.body() + } + + suspend fun getClubTeam(clubTeamId: Int): ClubTeamDto { + return client.http.get("/api/club-teams/$clubTeamId").body() + } + + suspend fun createClubTeam(clubId: Int, body: ClubTeamCreateBody): ClubTeamDto { + return client.http.post("/api/club-teams/club/$clubId") { + setBody(body) + }.body() + } + + suspend fun updateClubTeam(clubTeamId: Int, body: ClubTeamUpdateBody): ClubTeamDto { + return client.http.put("/api/club-teams/$clubTeamId") { + setBody(body) + }.body() + } + + suspend fun deleteClubTeam(clubTeamId: Int) { + client.http.delete("/api/club-teams/$clubTeamId") + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/OfficialTournamentsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/OfficialTournamentsApi.kt index 461187e8..3c40ebc5 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/OfficialTournamentsApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/OfficialTournamentsApi.kt @@ -2,9 +2,27 @@ 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.OfficialParsedTournamentEnvelopeDto +import de.tt_tagebuch.shared.api.models.OfficialPatchTournamentBody +import de.tt_tagebuch.shared.api.models.OfficialParticipantStatusBody +import de.tt_tagebuch.shared.api.models.OfficialParticipantStatusResponseDto import de.tt_tagebuch.shared.api.models.OfficialTournamentListRowDto +import de.tt_tagebuch.shared.api.models.OfficialTournamentUploadResultDto +import de.tt_tagebuch.shared.api.models.OfficialUpsertParticipationBody +import de.tt_tagebuch.shared.api.models.OfficialUpsertParticipationResponseDto import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import kotlinx.serialization.json.JsonElement class OfficialTournamentsApi( private val client: AuthedHttpClient, @@ -16,4 +34,65 @@ class OfficialTournamentsApi( suspend fun listParticipationSummary(clubId: Int): List { return client.http.get("/api/official-tournaments/$clubId/participations/summary").body() } + + suspend fun uploadPdf(clubId: Int, pdfBytes: ByteArray, fileName: String = "turnier.pdf"): OfficialTournamentUploadResultDto { + return client.http.post("/api/official-tournaments/$clubId/upload") { + contentType(ContentType.MultiPart.FormData) + setBody( + MultiPartFormDataContent( + formData { + append( + "pdf", + pdfBytes, + Headers.build { + append(HttpHeaders.ContentType, "application/pdf") + append( + HttpHeaders.ContentDisposition, + "filename=\"${fileName.replace("\"", "")}\"", + ) + }, + ) + }, + ), + ) + }.body() + } + + suspend fun getParsed(clubId: Int, tournamentId: Int): OfficialParsedTournamentEnvelopeDto { + return client.http.get("/api/official-tournaments/$clubId/$tournamentId").body() + } + + suspend fun patchTournament(clubId: Int, tournamentId: Int, body: OfficialPatchTournamentBody): JsonElement { + return client.http.patch("/api/official-tournaments/$clubId/$tournamentId") { + setBody(body) + }.body() + } + + suspend fun deleteTournament(clubId: Int, tournamentId: Int) { + client.http.delete("/api/official-tournaments/$clubId/$tournamentId") + } + + suspend fun upsertParticipation( + clubId: Int, + tournamentId: Int, + body: OfficialUpsertParticipationBody, + ): OfficialUpsertParticipationResponseDto { + return client.http.post("/api/official-tournaments/$clubId/$tournamentId/participation") { + setBody(body) + }.body() + } + + suspend fun updateParticipantStatus( + clubId: Int, + tournamentId: Int, + body: OfficialParticipantStatusBody, + ): OfficialParticipantStatusResponseDto { + return client.http.post("/api/official-tournaments/$clubId/$tournamentId/status") { + setBody(body) + }.body() + } + + suspend fun autoRegister(clubId: Int, tournamentId: Int): JsonElement { + return client.http.post("/api/official-tournaments/$clubId/$tournamentId/auto-register").body() + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/SeasonsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/SeasonsApi.kt new file mode 100644 index 00000000..9feb5042 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/SeasonsApi.kt @@ -0,0 +1,18 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.SeasonDto +import io.ktor.client.call.body +import io.ktor.client.request.get + +class SeasonsApi( + private val client: AuthedHttpClient, +) { + suspend fun listSeasons(): List { + return client.http.get("/api/seasons").body() + } + + suspend fun getCurrentSeason(): SeasonDto { + return client.http.get("/api/seasons/current").body() + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt index 5f73fbc4..9b39dd46 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt @@ -1,12 +1,57 @@ package de.tt_tagebuch.shared.api import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.AddExternalTournamentParticipantBody +import de.tt_tagebuch.shared.api.models.AddMiniChampionshipBody +import de.tt_tagebuch.shared.api.models.AddStandardTournamentBody +import de.tt_tagebuch.shared.api.models.AddTournamentClassBody +import de.tt_tagebuch.shared.api.models.AssignParticipantToGroupBody +import de.tt_tagebuch.shared.api.models.CreateTournamentPairingBody +import de.tt_tagebuch.shared.api.models.GaveUpFlagBody import de.tt_tagebuch.shared.api.models.InternalTournamentDetailDto import de.tt_tagebuch.shared.api.models.InternalTournamentStatsDto import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto +import de.tt_tagebuch.shared.api.models.MergeTournamentPoolBody +import de.tt_tagebuch.shared.api.models.RemoveExternalTournamentParticipantBody +import de.tt_tagebuch.shared.api.models.ResetTournamentPoolBody +import de.tt_tagebuch.shared.api.models.SeededFlagBody +import de.tt_tagebuch.shared.api.models.SetTournamentModusBody +import de.tt_tagebuch.shared.api.models.TournamentAddInternalParticipantBody +import de.tt_tagebuch.shared.api.models.TournamentAddMatchResultBody +import de.tt_tagebuch.shared.api.models.TournamentAdvanceStageBody +import de.tt_tagebuch.shared.api.models.TournamentClassDto +import de.tt_tagebuch.shared.api.models.TournamentCleanupOrphanedBody +import de.tt_tagebuch.shared.api.models.TournamentClubTournamentBody +import de.tt_tagebuch.shared.api.models.TournamentCreateGroupMatchesBody +import de.tt_tagebuch.shared.api.models.TournamentCreateGroupsBody +import de.tt_tagebuch.shared.api.models.TournamentCreateGroupsPerClassBody +import de.tt_tagebuch.shared.api.models.TournamentDeleteKnockoutBody +import de.tt_tagebuch.shared.api.models.TournamentDeleteMatchResultBody +import de.tt_tagebuch.shared.api.models.TournamentExternalParticipantRowDto +import de.tt_tagebuch.shared.api.models.TournamentFinishMatchBody +import de.tt_tagebuch.shared.api.models.TournamentGetExternalParticipantsBody +import de.tt_tagebuch.shared.api.models.TournamentGetParticipantsBody +import de.tt_tagebuch.shared.api.models.TournamentManualAssignGroupsBody +import de.tt_tagebuch.shared.api.models.TournamentMatchActiveBody +import de.tt_tagebuch.shared.api.models.TournamentMatchDto +import de.tt_tagebuch.shared.api.models.TournamentMatchTableBody +import de.tt_tagebuch.shared.api.models.TournamentParticipantRowDto +import de.tt_tagebuch.shared.api.models.TournamentRemoveInternalParticipantBody +import de.tt_tagebuch.shared.api.models.TournamentReopenMatchBody +import de.tt_tagebuch.shared.api.models.TournamentStartKnockoutBody +import de.tt_tagebuch.shared.api.models.TournamentUpsertStagesBody +import de.tt_tagebuch.shared.api.models.UpdateParticipantClassBody +import de.tt_tagebuch.shared.api.models.UpdateTournamentClassBody +import de.tt_tagebuch.shared.api.models.UpdateTournamentMetaBody +import de.tt_tagebuch.shared.api.models.UpdateTournamentPairingBody import io.ktor.client.call.body +import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import kotlinx.serialization.json.JsonElement class TournamentsApi( private val client: AuthedHttpClient, @@ -32,4 +77,303 @@ class TournamentsApi( ageClassKeys?.let { parameter("ageClassKeys", it) } }.body() } + + suspend fun addStandardTournament(body: AddStandardTournamentBody): InternalTournamentDetailDto { + return client.http.post("/api/tournament") { + setBody(body) + }.body() + } + + suspend fun addMiniChampionship(body: AddMiniChampionshipBody): InternalTournamentDetailDto { + return client.http.post("/api/tournament/mini") { + setBody(body) + }.body() + } + + suspend fun updateTournament(clubId: Int, tournamentId: Int, body: UpdateTournamentMetaBody): InternalTournamentDetailDto { + return client.http.put("/api/tournament/$clubId/$tournamentId") { + setBody(body) + }.body() + } + + suspend fun setModus(body: SetTournamentModusBody) { + client.http.post("/api/tournament/modus") { + setBody(body) + } + } + + suspend fun createEmptyGroups(body: TournamentCreateGroupsBody) { + client.http.put("/api/tournament/groups") { + setBody(body) + } + } + + suspend fun createGroupsPerClass(body: TournamentCreateGroupsPerClassBody) { + client.http.post("/api/tournament/groups/create") { + setBody(body) + } + } + + suspend fun fillGroups(body: TournamentClubTournamentBody): List { + return client.http.post("/api/tournament/groups") { + setBody(body) + }.body() + } + + suspend fun createGroupMatches(body: TournamentCreateGroupMatchesBody) { + client.http.post("/api/tournament/matches/create") { + setBody(body) + } + } + + suspend fun resetGroups(body: TournamentClubTournamentBody) { + client.http.post("/api/tournament/groups/reset") { + setBody(body) + } + } + + suspend fun resetMatches(body: TournamentClubTournamentBody) { + client.http.post("/api/tournament/matches/reset") { + setBody(body) + } + } + + suspend fun cleanupOrphanedMatches(body: TournamentCleanupOrphanedBody) { + client.http.post("/api/tournament/matches/cleanup-orphaned") { + setBody(body) + } + } + + suspend fun getGroups(clubId: Int, tournamentId: Int): JsonElement { + return client.http.get("/api/tournament/groups") { + parameter("clubId", clubId) + parameter("tournamentId", tournamentId) + }.body() + } + + suspend fun manualAssignGroups(body: TournamentManualAssignGroupsBody): JsonElement { + return client.http.post("/api/tournament/groups/manual") { + setBody(body) + }.body() + } + + suspend fun assignParticipantToGroup(body: AssignParticipantToGroupBody) { + client.http.put("/api/tournament/participant/group") { + setBody(body) + } + } + + suspend fun mergeClassesIntoPool(body: MergeTournamentPoolBody) { + client.http.post("/api/tournament/pools/merge") { + setBody(body) + } + } + + suspend fun resetPool(body: ResetTournamentPoolBody) { + client.http.post("/api/tournament/pools/reset") { + setBody(body) + } + } + + suspend fun listTournamentClasses(clubId: Int, tournamentId: Int): List { + return client.http.get("/api/tournament/classes/$clubId/$tournamentId").body() + } + + suspend fun addTournamentClass(clubId: Int, tournamentId: Int, body: AddTournamentClassBody): TournamentClassDto { + return client.http.post("/api/tournament/class/$clubId/$tournamentId") { + setBody(body) + }.body() + } + + suspend fun updateTournamentClass( + clubId: Int, + tournamentId: Int, + classId: Int, + body: UpdateTournamentClassBody, + ): TournamentClassDto { + return client.http.put("/api/tournament/class/$clubId/$tournamentId/$classId") { + setBody(body) + }.body() + } + + suspend fun deleteTournamentClass(clubId: Int, tournamentId: Int, classId: Int) { + client.http.delete("/api/tournament/class/$clubId/$tournamentId/$classId") + } + + suspend fun listInternalParticipants(body: TournamentGetParticipantsBody): List { + return client.http.post("/api/tournament/participants") { + setBody(body) + }.body() + } + + suspend fun addInternalParticipant(body: TournamentAddInternalParticipantBody): List { + return client.http.post("/api/tournament/participant") { + setBody(body) + }.body() + } + + suspend fun removeInternalParticipant(body: TournamentRemoveInternalParticipantBody) { + client.http.delete("/api/tournament/participant") { + setBody(body) + } + } + + suspend fun updateParticipantClass( + clubId: Int, + tournamentId: Int, + participantId: Int, + body: UpdateParticipantClassBody, + ) { + client.http.put("/api/tournament/participant/$clubId/$tournamentId/$participantId/class") { + setBody(body) + } + } + + suspend fun updateParticipantSeeded(clubId: Int, tournamentId: Int, participantId: Int, body: SeededFlagBody) { + client.http.put("/api/tournament/participant/$clubId/$tournamentId/$participantId/seeded") { + setBody(body) + } + } + + suspend fun setParticipantGaveUp(clubId: Int, tournamentId: Int, participantId: Int, body: GaveUpFlagBody) { + client.http.put("/api/tournament/participant/$clubId/$tournamentId/$participantId/gave-up") { + setBody(body) + } + } + + suspend fun listExternalParticipants(body: TournamentGetExternalParticipantsBody): List { + return client.http.post("/api/tournament/external-participants") { + setBody(body) + }.body() + } + + suspend fun addExternalParticipant(body: AddExternalTournamentParticipantBody) { + client.http.post("/api/tournament/external-participant") { + setBody(body) + } + } + + suspend fun removeExternalParticipant(body: RemoveExternalTournamentParticipantBody) { + client.http.delete("/api/tournament/external-participant") { + setBody(body) + } + } + + suspend fun updateExternalParticipantSeeded( + clubId: Int, + tournamentId: Int, + participantId: Int, + body: SeededFlagBody, + ) { + client.http.put("/api/tournament/external-participant/$clubId/$tournamentId/$participantId/seeded") { + setBody(body) + } + } + + suspend fun setExternalParticipantGaveUp( + clubId: Int, + tournamentId: Int, + participantId: Int, + body: GaveUpFlagBody, + ) { + client.http.put("/api/tournament/external-participant/$clubId/$tournamentId/$participantId/gave-up") { + setBody(body) + } + } + + suspend fun listMatches(clubId: Int, tournamentId: Int): List { + return client.http.get("/api/tournament/matches/$clubId/$tournamentId").body() + } + + suspend fun addMatchResult(body: TournamentAddMatchResultBody) { + client.http.post("/api/tournament/match/result") { + setBody(body) + } + } + + suspend fun deleteMatchResult(body: TournamentDeleteMatchResultBody) { + client.http.delete("/api/tournament/match/result") { + setBody(body) + } + } + + suspend fun finishMatch(body: TournamentFinishMatchBody) { + client.http.post("/api/tournament/match/finish") { + setBody(body) + } + } + + suspend fun reopenMatch(body: TournamentReopenMatchBody) { + client.http.post("/api/tournament/match/reopen") { + setBody(body) + } + } + + suspend fun setMatchActive(clubId: Int, tournamentId: Int, matchId: Int, body: TournamentMatchActiveBody) { + client.http.put("/api/tournament/match/$clubId/$tournamentId/$matchId/active") { + setBody(body) + } + } + + suspend fun setMatchTable(clubId: Int, tournamentId: Int, matchId: Int, body: TournamentMatchTableBody) { + client.http.put("/api/tournament/match/$clubId/$tournamentId/$matchId/table") { + setBody(body) + } + } + + suspend fun startKnockout(body: TournamentStartKnockoutBody) { + client.http.post("/api/tournament/knockout") { + setBody(body) + } + } + + suspend fun deleteKnockoutMatches(body: TournamentDeleteKnockoutBody) { + client.http.delete("/api/tournament/matches/knockout") { + setBody(body) + } + } + + suspend fun getStages(clubId: Int, tournamentId: Int): JsonElement { + return client.http.get("/api/tournament/stages") { + parameter("clubId", clubId) + parameter("tournamentId", tournamentId) + }.body() + } + + suspend fun upsertStages(body: TournamentUpsertStagesBody): JsonElement { + return client.http.put("/api/tournament/stages") { + setBody(body) + }.body() + } + + suspend fun advanceStage(body: TournamentAdvanceStageBody): JsonElement { + return client.http.post("/api/tournament/stages/advance") { + setBody(body) + }.body() + } + + suspend fun listPairings(clubId: Int, tournamentId: Int, classId: Int): JsonElement { + return client.http.get("/api/tournament/pairings/$clubId/$tournamentId/$classId").body() + } + + suspend fun createPairing(clubId: Int, tournamentId: Int, classId: Int, body: CreateTournamentPairingBody): JsonElement { + return client.http.post("/api/tournament/pairing/$clubId/$tournamentId/$classId") { + setBody(body) + }.body() + } + + suspend fun updatePairing( + clubId: Int, + tournamentId: Int, + pairingId: Int, + body: UpdateTournamentPairingBody, + ): JsonElement { + return client.http.put("/api/tournament/pairing/$clubId/$tournamentId/$pairingId") { + setBody(body) + }.body() + } + + suspend fun deletePairing(clubId: Int, tournamentId: Int, pairingId: Int) { + client.http.delete("/api/tournament/pairing/$clubId/$tournamentId/$pairingId") + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/OfficialTournamentWorkspaceDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/OfficialTournamentWorkspaceDtos.kt new file mode 100644 index 00000000..65edfeaf --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/OfficialTournamentWorkspaceDtos.kt @@ -0,0 +1,112 @@ +package de.tt_tagebuch.shared.api.models + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class OfficialTournamentUploadResultDto( + val id: String, +) + +@Serializable +data class OfficialPatchTournamentBody( + val title: String? = null, +) + +@Serializable +data class OfficialUpsertParticipationBody( + val competitionId: Int, + val memberId: Int, + val wants: Boolean = false, + val registered: Boolean = false, + val participated: Boolean = false, + val placement: String? = null, +) + +@Serializable +data class OfficialParticipantStatusBody( + val competitionId: Int, + val memberId: Int, + val action: String, +) + +@Serializable +data class OfficialCompetitionMemberStateDto( + val id: Int? = null, + val tournamentId: Int? = null, + val competitionId: Int = 0, + val memberId: Int = 0, + val wants: Boolean = false, + val registered: Boolean = false, + val participated: Boolean = false, + val placement: String? = null, +) + +@Serializable +data class OfficialParsedCompetitionDto( + val id: Int = 0, + val tournamentId: Int? = null, + val ageClassCompetition: String? = null, + val altersklasseWettbewerb: String? = null, + val performanceClass: String? = null, + val leistungsklasse: String? = null, + val startTime: String? = null, + val startzeit: String? = null, + val openTo: String? = null, + val offenFuer: String? = null, + val cutoffDate: String? = null, + val stichtag: String? = null, + val registrationDeadlineDate: String? = null, + val registrationDeadlineOnline: String? = null, + val meldeschlussDatum: String? = null, + val meldeschlussOnline: String? = null, + val preliminaryRound: String? = null, + val vorrunde: String? = null, + val finalRound: String? = null, + val endrunde: String? = null, + val entryFee: String? = null, + val startgeld: String? = null, + val ttrRelevant: String? = null, + val maxParticipants: Int? = null, + val maxTeilnehmer: Int? = null, +) + +@Serializable +data class OfficialParsedDataDto( + val title: String? = null, + val termin: String? = null, + val austragungsorte: JsonElement? = null, + val konkurrenztypen: JsonElement? = null, + val meldeschluesse: JsonElement? = null, + val entryFees: JsonElement? = null, + val competitions: List = emptyList(), +) + +@Serializable +data class OfficialParsedTournamentEnvelopeDto( + val id: String? = null, + val clubId: String? = null, + val parsedData: OfficialParsedDataDto? = null, + val participation: List = emptyList(), +) + +@Serializable +data class OfficialUpsertParticipationResponseDto( + val success: Boolean = false, + val id: Int? = null, +) + +@Serializable +data class OfficialParticipantStatusSnapshotDto( + val wants: Boolean = false, + val registered: Boolean = false, + val participated: Boolean = false, + val placement: String? = null, +) + +@Serializable +data class OfficialParticipantStatusResponseDto( + val success: Boolean = false, + val id: Int? = null, + val status: OfficialParticipantStatusSnapshotDto? = null, +) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Schedule.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Schedule.kt index 3a8bd325..71e01c94 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Schedule.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Schedule.kt @@ -14,9 +14,38 @@ data class ClubTeamLeagueDto( @Serializable data class ClubTeamSeasonDto( + val id: Int? = null, val season: String = "", ) +/** Option aus `GET /api/club-teams/leagues/:clubId`. */ +@Serializable +data class ClubLeagueOptionDto( + val id: Int, + val name: String = "", + val seasonId: Int? = null, +) + +@Serializable +data class ClubTeamCreateBody( + val name: String, + val leagueId: Int? = null, + val seasonId: Int? = null, + val teamGender: String? = null, + val teamAgeGroup: String? = null, + val plannedLeagueName: String? = null, +) + +@Serializable +data class ClubTeamUpdateBody( + val name: String? = null, + val leagueId: Int? = null, + val seasonId: Int? = null, + val teamGender: String? = null, + val teamAgeGroup: String? = null, + val plannedLeagueName: String? = null, +) + @Serializable data class ClubTeamDto( val id: Int, diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/SeasonDto.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/SeasonDto.kt new file mode 100644 index 00000000..0fa220c3 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/SeasonDto.kt @@ -0,0 +1,9 @@ +package de.tt_tagebuch.shared.api.models + +import kotlinx.serialization.Serializable + +@Serializable +data class SeasonDto( + val id: Int = 0, + val season: String = "", +) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentEditorDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentEditorDtos.kt new file mode 100644 index 00000000..106f2114 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentEditorDtos.kt @@ -0,0 +1,381 @@ +package de.tt_tagebuch.shared.api.models + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +// --- Standard CRUD / Stammdaten --- + +@Serializable +data class AddStandardTournamentBody( + val clubId: Int, + val tournamentName: String, + val date: String, + val winningSets: Int? = null, + val allowsExternal: Boolean? = null, + val isDoublesTournament: Boolean? = false, +) + +@Serializable +data class AddMiniChampionshipBody( + val clubId: Int, + val ort: String, + val date: String, + val year: Int, + val winningSets: Int? = null, +) + +@Serializable +data class UpdateTournamentMetaBody( + val name: String? = null, + val date: String? = null, + val winningSets: Int? = null, + val numberOfTables: Int? = null, + val isDoublesTournament: Boolean? = null, +) + +// --- Modus / Gruppen / Spiele --- + +@Serializable +data class SetTournamentModusBody( + val clubId: Int, + val tournamentId: Int, + val type: String, + val numberOfGroups: Int? = null, + val advancingPerGroup: Int? = null, +) + +@Serializable +data class TournamentClubTournamentBody( + val clubId: Int, + val tournamentId: Int, +) + +@Serializable +data class TournamentCleanupOrphanedBody( + val clubId: Int, + val tournamentId: Int, +) + +@Serializable +data class TournamentCreateGroupsBody( + val clubId: Int, + val tournamentId: Int, + val numberOfGroups: Int? = null, +) + +@Serializable +data class TournamentCreateGroupMatchesBody( + val clubId: Int, + val tournamentId: Int, + val classId: Int? = null, +) + +@Serializable +data class TournamentCreateGroupsPerClassBody( + val clubId: Int, + val tournamentId: Int, + val groupsPerClass: Map = emptyMap(), +) + +@Serializable +data class ManualGroupAssignmentEntry( + val participantId: Int, + val groupNumber: Int, +) + +@Serializable +data class TournamentManualAssignGroupsBody( + val clubId: Int, + val tournamentId: Int, + val assignments: List = emptyList(), + val numberOfGroups: Int? = null, + val maxGroupSize: Int? = null, +) + +@Serializable +data class AssignParticipantToGroupBody( + val clubId: Int, + val tournamentId: Int, + val participantId: Int, + val groupNumber: Int, + val isExternal: Boolean = false, +) + +@Serializable +data class MergeTournamentPoolBody( + val clubId: Int, + val tournamentId: Int, + val sourceClassId: Int, + val targetClassId: Int, + val strategy: String, + val outOfCompetitionForSource: Boolean = false, +) + +@Serializable +data class ResetTournamentPoolBody( + val clubId: Int, + val tournamentId: Int, + val poolId: Int, +) + +// --- Teilnehmer --- + +@Serializable +data class TournamentGetParticipantsBody( + val clubId: Int, + val tournamentId: Int, + val classId: Int? = null, +) + +@Serializable +data class TournamentAddInternalParticipantBody( + val clubId: Int, + val classId: Int? = null, + val participant: Int, + val tournamentId: Int? = null, +) + +@Serializable +data class TournamentRemoveInternalParticipantBody( + val clubId: Int, + val tournamentId: Int, + val participantId: Int, +) + +@Serializable +data class TournamentGetExternalParticipantsBody( + val clubId: Int, + val tournamentId: Int, + val classId: Int? = null, +) + +@Serializable +data class AddExternalTournamentParticipantBody( + val clubId: Int, + val tournamentId: Int, + val classId: Int? = null, + val firstName: String, + val lastName: String, + val club: String? = null, + val birthDate: String? = null, + val gender: String? = null, + val email: String? = null, + val address: String? = null, +) + +@Serializable +data class RemoveExternalTournamentParticipantBody( + val clubId: Int, + val tournamentId: Int, + val participantId: Int, +) + +@Serializable +data class UpdateParticipantClassBody( + val classId: Int? = null, + val isExternal: Boolean? = false, +) + +@Serializable +data class SeededFlagBody(val seeded: Boolean) + +@Serializable +data class GaveUpFlagBody(val gaveUp: Boolean) + +// --- Klassen --- + +@Serializable +data class AddTournamentClassBody( + val name: String, + val isDoubles: Boolean? = false, + val gender: String? = null, + val minBirthYear: Int? = null, + val maxBirthYear: Int? = null, +) + +@Serializable +data class UpdateTournamentClassBody( + val name: String? = null, + val sortOrder: Int? = null, + val isDoubles: Boolean? = null, + val gender: String? = null, + val minBirthYear: Int? = null, + val maxBirthYear: Int? = null, +) + +@Serializable +data class TournamentClassDto( + val id: Int = 0, + val tournamentId: Int? = null, + val name: String = "", + val sortOrder: Int? = null, + @Serializable(with = FlexibleNullableBooleanSerializer::class) + val isDoubles: Boolean? = null, + val gender: String? = null, + val minBirthYear: Int? = null, + val maxBirthYear: Int? = null, +) + +// --- Teilnehmer-Zeilen (intern) --- + +@Serializable +data class TournamentParticipantMemberSnippetDto( + val id: Int? = null, + val firstName: String? = null, + val lastName: String? = null, + val gender: String? = null, +) + +@Serializable +data class TournamentParticipantRowDto( + val id: Int = 0, + val tournamentId: Int? = null, + val clubMemberId: Int? = null, + val classId: Int? = null, + val groupId: Int? = null, + @Serializable(with = FlexibleNullableBooleanSerializer::class) + val seeded: Boolean? = null, + @Serializable(with = FlexibleNullableBooleanSerializer::class) + val gaveUp: Boolean? = null, + val member: TournamentParticipantMemberSnippetDto? = null, +) + +@Serializable +data class TournamentExternalParticipantRowDto( + val id: Int = 0, + val tournamentId: Int? = null, + val classId: Int? = null, + val firstName: String? = null, + val lastName: String? = null, + val club: String? = null, + @Serializable(with = FlexibleNullableBooleanSerializer::class) + val seeded: Boolean? = null, + @Serializable(with = FlexibleNullableBooleanSerializer::class) + val gaveUp: Boolean? = null, +) + +// --- Matches --- + +@Serializable +data class TournamentMatchSetResultDto( + val set: Int = 0, + val pointsPlayer1: Int? = null, + val pointsPlayer2: Int? = null, +) + +@Serializable +data class TournamentMatchDto( + val id: Int = 0, + val tournamentId: Int? = null, + val groupId: Int? = null, + val classId: Int? = null, + val groupRound: Int? = null, + val round: String = "", + val player1Id: Int? = null, + val player2Id: Int? = null, + @Serializable(with = FlexibleNullableBooleanSerializer::class) + val isFinished: Boolean? = null, + @Serializable(with = FlexibleNullableBooleanSerializer::class) + val isActive: Boolean? = null, + val result: String? = null, + val tableNumber: Int? = null, + val player1: JsonElement? = null, + val player2: JsonElement? = null, + val tournamentResults: List? = null, +) + +@Serializable +data class TournamentAddMatchResultBody( + val clubId: Int, + val tournamentId: Int, + val matchId: Int, + val set: Int, + val result: String, +) + +@Serializable +data class TournamentFinishMatchBody( + val clubId: Int, + val tournamentId: Int, + val matchId: Int, +) + +@Serializable +data class TournamentMatchActiveBody( + val isActive: Boolean, +) + +@Serializable +data class TournamentMatchTableBody( + val tableNumber: Int? = null, +) + +@Serializable +data class TournamentDeleteMatchResultBody( + val clubId: Int, + val tournamentId: Int, + val matchId: Int, + val set: Int, +) + +@Serializable +data class TournamentReopenMatchBody( + val clubId: Int, + val tournamentId: Int, + val matchId: Int, +) + +@Serializable +data class TournamentDeleteKnockoutBody( + val clubId: Int, + val tournamentId: Int, + val classId: Int? = null, +) + +// --- KO --- + +@Serializable +data class TournamentStartKnockoutBody( + val clubId: Int, + val tournamentId: Int, +) + +// --- Paarungen --- + +@Serializable +data class CreateTournamentPairingBody( + val player1Type: String, + val player1Id: Int, + val player2Type: String, + val player2Id: Int, + val seeded: Boolean? = false, +) + +@Serializable +data class UpdateTournamentPairingBody( + val player1Type: String? = null, + val player1Id: Int? = null, + val player2Type: String? = null, + val player2Id: Int? = null, + val seeded: Boolean? = null, + val groupId: Int? = null, +) + +// --- Stages --- + +@Serializable +data class TournamentAdvanceStageBody( + val clubId: Int, + val tournamentId: Int, + val fromStageIndex: Int? = 1, + val toStageIndex: Int? = null, +) + +@Serializable +data class TournamentUpsertStagesBody( + val clubId: Int, + val tournamentId: Int, + val stages: JsonElement? = null, + val advancement: JsonElement? = null, + val advancements: JsonElement? = null, +)