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