feat(Tournament): update tournament participation UI and localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
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:
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user