feat(Tournament): update tournament participation UI and localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Changed the icon for tournament participations in the navigation to better represent the feature.
- Updated localization keys for tournament participations across multiple languages, enhancing clarity and user understanding.
- Introduced a new tab for official tournament participations in the mobile app, improving navigation and access to tournament details.
- Enhanced the TournamentsScreen to include options for creating and managing official tournaments, streamlining user interactions.
This commit is contained in:
Torsten Schulz (local)
2026-05-14 17:51:19 +02:00
parent 3d1dfe9a4c
commit 2f3f4fb275
24 changed files with 5472 additions and 58 deletions

View File

@@ -106,7 +106,7 @@
{{ $t('navigation.clubTournaments') }}
</router-link>
<router-link v-if="hasPermission('tournaments', 'read')" to="/tournament-participations" class="nav-link" :title="$t('navigation.tournamentParticipations')">
<span class="nav-icon">📄</span>
<span class="nav-icon">📋</span>
{{ $t('navigation.tournamentParticipations') }}
</router-link>
<router-link v-if="hasPermission('schedule', 'read')" to="/schedule" class="nav-link" title="Spielpläne">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -167,7 +167,7 @@ private fun visibleMainTabs(perms: UserClubPermissions?): List<MainTab> =
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")
}

View File

@@ -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<TournamentClassDto>,
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<TournamentClassDto>,
participants: List<TournamentParticipantRowDto>,
externalParticipants: List<TournamentExternalParticipantRowDto>,
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<TournamentClassDto>,
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<TournamentMatchDto>,
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<TournamentClassDto>,
participants: List<TournamentParticipantRowDto>,
externalParticipants: List<TournamentExternalParticipantRowDto>,
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<JsonElement?>(null) }
var p1Menu by remember { mutableStateOf(false) }
var p2Menu by remember { mutableStateOf(false) }
var sel1 by remember { mutableStateOf<Pair<String, Int>?>(null) }
var sel2 by remember { mutableStateOf<Pair<String, Int>?>(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"
}

View File

@@ -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<String?>(null) }
var detail by remember { mutableStateOf<InternalTournamentDetailDto?>(null) }
var classes by remember { mutableStateOf<List<TournamentClassDto>>(emptyList()) }
var participants by remember { mutableStateOf<List<TournamentParticipantRowDto>>(emptyList()) }
var externalParticipants by remember { mutableStateOf<List<TournamentExternalParticipantRowDto>>(emptyList()) }
var matches by remember { mutableStateOf<List<TournamentMatchDto>>(emptyList()) }
var groupsJson by remember { mutableStateOf<JsonElement?>(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<TournamentClassDto>,
val participants: List<TournamentParticipantRowDto>,
val external: List<TournamentExternalParticipantRowDto>,
val matches: List<TournamentMatchDto>,
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),
)
}
}

View File

@@ -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<ParticipantTableRow>,
)
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<OfficialCompetitionMemberStateDto>): Map<String, ParticipationState> {
val out = LinkedHashMap<String, ParticipationState>()
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<String, ParticipationState>,
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<Calendar?, Calendar?> {
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<OfficialTournamentListRowDto>,
tournamentSearch: String,
showOlderTournaments: Boolean,
selectedTournamentId: Int?,
): List<OfficialTournamentListRowDto> {
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<String> {
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<String>()
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<String>()
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<String, ParticipationState>): Pair<Int, Int> {
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<String, ParticipationState>): Int =
map.values.count { it.wants && !it.registered && !it.participated }
internal fun canAutoRegister(selectedId: Int?, map: Map<String, ParticipationState>, 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<String, ParticipationState>): List<WorkflowNotice> {
val notices = mutableListOf<WorkflowNotice>()
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, ParticipationState>): 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<Member>,
map: Map<String, ParticipationState>,
): 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<Member>,
map: Map<String, ParticipationState>,
memberNameById: (Int) -> String,
): List<ActiveCompetitionMemberRow> {
val rows = mutableListOf<ActiveCompetitionMemberRow>()
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<String, ParticipationState>,
participantsFilter: String,
memberNameById: (Int) -> String,
): List<ParticipantTableRow> {
if (parsed?.parsedData == null) return emptyList()
val comps = parsed.parsedData!!.competitions
val compById = comps.associateBy { it.id.toString() }
val seen = mutableSetOf<String>()
val merged = mutableListOf<Pair<String, String>>()
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<ParticipantTableRow>()
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<ParticipantTableRow>): List<ParticipantGroup> {
val byMember = LinkedHashMap<String, MutableList<ParticipantTableRow>>()
for (row in rows) {
byMember.getOrPut(row.memberName) { mutableListOf() }.add(row)
}
val groups = mutableListOf<ParticipantGroup>()
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<String, ParticipationState>,
memberNameById: (Int) -> String,
): List<ParticipantTableRow> {
if (parsed?.parsedData == null) return emptyList()
val comps = parsed.parsedData!!.competitions
val compById = comps.associateBy { it.id.toString() }
val rows = mutableListOf<ParticipantTableRow>()
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<ParticipantTableRow>): List<ParticipantGroup> =
participantsGroups(rows)
internal fun splitDateTime(str: String): Pair<String, String> {
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<CompetitionForMemberRow> {
val comps = parsed?.parsedData?.competitions ?: return emptyList()
val rows = mutableListOf<CompetitionForMemberRow>()
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<OfficialParticipationBucketDto>,
range: OfficialParticipationRange,
): List<ClubHistoryRow> {
val now = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
val (from, to) = participationRangeBoundsLocal(now, range)
val rows = mutableListOf<ClubHistoryRow>()
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<java.time.LocalDate?, java.time.LocalDate?> {
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<ClubHistoryRow>,
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 }
}

View File

@@ -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<List<SeasonDto>>(emptyList()) }
var selectedSeasonId by remember { mutableStateOf<Int?>(null) }
var teams by remember { mutableStateOf<List<ClubTeamDto>>(emptyList()) }
var leagues by remember { mutableStateOf<List<ClubLeagueOptionDto>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var search by remember { mutableStateOf("") }
var showForm by remember { mutableStateOf(false) }
var formIsNew by remember { mutableStateOf(true) }
var formTeamId by remember { mutableStateOf<Int?>(null) }
var formName by remember { mutableStateOf("") }
var formLeagueId by remember { mutableStateOf<Int?>(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<ClubTeamDto?>(null) }
var infoMessage by remember { mutableStateOf<String?>(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

View File

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

View File

@@ -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<Member>,
): List<Member> {
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()
}
}

View File

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

View File

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

View File

@@ -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<SeasonDto> {
return client.http.get("/api/seasons").body()
}
suspend fun getCurrentSeason(): SeasonDto {
return client.http.get("/api/seasons/current").body()
}
}

View File

@@ -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<TournamentParticipantRowDto> {
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<TournamentClassDto> {
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<TournamentParticipantRowDto> {
return client.http.post("/api/tournament/participants") {
setBody(body)
}.body()
}
suspend fun addInternalParticipant(body: TournamentAddInternalParticipantBody): List<TournamentParticipantRowDto> {
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<TournamentExternalParticipantRowDto> {
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<TournamentMatchDto> {
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")
}
}

View File

@@ -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<OfficialParsedCompetitionDto> = emptyList(),
)
@Serializable
data class OfficialParsedTournamentEnvelopeDto(
val id: String? = null,
val clubId: String? = null,
val parsedData: OfficialParsedDataDto? = null,
val participation: List<OfficialCompetitionMemberStateDto> = 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,
)

View File

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

View File

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

View File

@@ -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<String, Int> = emptyMap(),
)
@Serializable
data class ManualGroupAssignmentEntry(
val participantId: Int,
val groupNumber: Int,
)
@Serializable
data class TournamentManualAssignGroupsBody(
val clubId: Int,
val tournamentId: Int,
val assignments: List<ManualGroupAssignmentEntry> = 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<TournamentMatchSetResultDto>? = 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,
)