feat: Implement friendly match management features
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Added backend support for managing friendly matches including listing, creating, updating, and deleting matches. - Introduced a new database table `friendly_match` with relevant fields for match details. - Created a service layer to handle business logic related to friendly matches. - Developed API routes for friendly match operations with appropriate authentication and authorization. - Added a Vue component for managing participants in friendly matches, allowing selection of members and manual entry of names. - Updated existing tournament editor screens to integrate friendly match functionalities.
This commit is contained in:
@@ -71,6 +71,7 @@ import de.tsschulz.tt_tagebuch.shared.api.models.InternalTournamentDetailDto
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentClubTournamentBody
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
@@ -79,6 +80,9 @@ import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.background
|
||||
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentMatchActiveBody
|
||||
|
||||
@Composable
|
||||
internal fun TournamentEditorClassesTab(
|
||||
@@ -96,6 +100,7 @@ internal fun TournamentEditorClassesTab(
|
||||
var newDoubles by remember { mutableStateOf(false) }
|
||||
var showDistributedDialog by remember { mutableStateOf(false) }
|
||||
var distributedMatches by remember { mutableStateOf<List<TournamentMatchDto>>(emptyList()) }
|
||||
var distributedMessage by remember { mutableStateOf<String?>(null) }
|
||||
if (showAdd) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showAdd = false },
|
||||
@@ -147,12 +152,26 @@ internal fun TournamentEditorClassesTab(
|
||||
val resp = withContext(Dispatchers.IO) {
|
||||
api.distributeTables(de.tsschulz.tt_tagebuch.shared.api.models.TournamentClubTournamentBody(clubId, tournamentId))
|
||||
}
|
||||
// If server returned updated matches, fetch full match details to show names
|
||||
if (resp.updated.isNotEmpty()) {
|
||||
val allMatches = withContext(Dispatchers.IO) { api.listMatches(clubId, tournamentId) }
|
||||
val ids = resp.updated.map { it.id }.toSet()
|
||||
distributedMatches = allMatches.filter { it.id in ids }
|
||||
|
||||
// If server returned no updates, show info message
|
||||
if (resp.updated.isEmpty()) {
|
||||
// show simple AlertDialog with server message or default
|
||||
showDistributedDialog = true
|
||||
distributedMatches = emptyList()
|
||||
distributedMessage = resp.message ?: tr("tournaments.tablesDistributed", "Tische wurden verteilt.")
|
||||
} else {
|
||||
// If server already included player objects, use them directly
|
||||
val firstHasPlayers = resp.updated.firstOrNull()?.player1 != null || resp.updated.firstOrNull()?.player2 != null
|
||||
if (firstHasPlayers) {
|
||||
distributedMatches = resp.updated
|
||||
showDistributedDialog = true
|
||||
} else {
|
||||
// fallback: fetch full matches to extract player info
|
||||
val allMatches = withContext(Dispatchers.IO) { api.listMatches(clubId, tournamentId) }
|
||||
val ids = resp.updated.map { it.id }.toSet()
|
||||
distributedMatches = allMatches.filter { it.id in ids }
|
||||
showDistributedDialog = true
|
||||
}
|
||||
}
|
||||
}.onFailure { onError(it.message) }
|
||||
onReload()
|
||||
@@ -167,24 +186,29 @@ internal fun TournamentEditorClassesTab(
|
||||
title = { Text(tr("tournaments.distributeTablesResult", "Tischverteilung")) },
|
||||
text = {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
// Header
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Text(tr("tournaments.playerA", "Spieler A"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.playerB", "Spieler B"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.tableShort", "Tisch"), modifier = Modifier.width(48.dp), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Divider()
|
||||
LazyColumn {
|
||||
items(distributedMatches) { m ->
|
||||
val name1 = extractPlayerName(m.player1)
|
||||
val name2 = extractPlayerName(m.player2)
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(name1, modifier = Modifier.weight(1f))
|
||||
Text("–", modifier = Modifier.width(16.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
Text(name2, modifier = Modifier.weight(1f))
|
||||
Text((m.tableNumber ?: "-").toString(), modifier = Modifier.width(48.dp))
|
||||
if (distributedMatches.isEmpty()) {
|
||||
// No assignments returned
|
||||
Text(distributedMessage ?: tr("tournaments.tablesDistributed", "Tische wurden verteilt."))
|
||||
} else {
|
||||
// Header
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Text(tr("tournaments.playerA", "Spieler A"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.playerB", "Spieler B"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.tableShort", "Tisch"), modifier = Modifier.width(48.dp), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Divider()
|
||||
LazyColumn {
|
||||
items(distributedMatches) { m ->
|
||||
val name1 = extractPlayerName(m.player1)
|
||||
val name2 = extractPlayerName(m.player2)
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(name1, modifier = Modifier.weight(1f))
|
||||
Text("–", modifier = Modifier.width(16.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
Text(name2, modifier = Modifier.weight(1f))
|
||||
Text((m.tableNumber ?: "-").toString(), modifier = Modifier.width(48.dp))
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,6 +529,7 @@ internal fun TournamentEditorMatchesTab(
|
||||
matches: List<TournamentMatchDto>,
|
||||
winningSets: Int,
|
||||
detail: InternalTournamentDetailDto,
|
||||
groupsJson: JsonElement?,
|
||||
tr: (String, String) -> String,
|
||||
api: TournamentsApi,
|
||||
scope: CoroutineScope,
|
||||
@@ -519,6 +544,10 @@ internal fun TournamentEditorMatchesTab(
|
||||
) {
|
||||
var confirmDelete by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
val hasKO = matches.any { (it.round ?: "").lowercase() != "group" && (it.round ?: "").isNotBlank() }
|
||||
var showDistributedDialog by remember { mutableStateOf(false) }
|
||||
var distributedMatches by remember { mutableStateOf<List<TournamentMatchDto>>(emptyList()) }
|
||||
var distributedMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
@@ -546,6 +575,32 @@ internal fun TournamentEditorMatchesTab(
|
||||
},
|
||||
) { Text(tr("tournaments.startKnockout", "K.-o. starten")) }
|
||||
}
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val resp = withContext(Dispatchers.IO) {
|
||||
api.distributeTables(TournamentClubTournamentBody(clubId, tournamentId))
|
||||
}
|
||||
if (resp.updated.isEmpty()) {
|
||||
showDistributedDialog = true
|
||||
distributedMatches = emptyList()
|
||||
distributedMessage = resp.message ?: tr("tournaments.tablesDistributed", "Tische wurden verteilt.")
|
||||
} else {
|
||||
val firstHasPlayers = resp.updated.firstOrNull()?.player1 != null || resp.updated.firstOrNull()?.player2 != null
|
||||
if (firstHasPlayers) {
|
||||
distributedMatches = resp.updated
|
||||
showDistributedDialog = true
|
||||
} else {
|
||||
val allMatches = withContext(Dispatchers.IO) { api.listMatches(clubId, tournamentId) }
|
||||
val ids = resp.updated.map { it.id }.toSet()
|
||||
distributedMatches = allMatches.filter { it.id in ids }
|
||||
showDistributedDialog = true
|
||||
}
|
||||
}
|
||||
}.onFailure { onError(it.message) }
|
||||
onReload()
|
||||
}
|
||||
}) { Text(tr("tournaments.distributeTables", "Freie Tische verteilen")) }
|
||||
}
|
||||
// Table-like view: show header + flattened rows so Android resembles web UI
|
||||
val displayList = matches.sortedWith(compareBy<TournamentMatchDto> {
|
||||
@@ -555,6 +610,28 @@ internal fun TournamentEditorMatchesTab(
|
||||
it.groupRound ?: it.round.toIntOrNull() ?: Int.MAX_VALUE
|
||||
}.thenBy { it.groupId ?: Int.MAX_VALUE }.thenBy { it.groupRound ?: 0 })
|
||||
|
||||
// build group id -> sequential number map from groupsJson (order returned by backend)
|
||||
val groupNumberById = remember(groupsJson) {
|
||||
val map = mutableMapOf<Int, Int>()
|
||||
try {
|
||||
if (groupsJson is JsonArray) {
|
||||
var idx = 1
|
||||
for (g in groupsJson) {
|
||||
// Backend returns groups as { groupId, classId, groupNumber, participants }
|
||||
val id = g.jsonObject["groupId"]?.jsonPrimitive?.intOrNull
|
||||
?: g.jsonObject["id"]?.jsonPrimitive?.intOrNull
|
||||
if (id != null) {
|
||||
map[id] = idx
|
||||
idx++
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
// header
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Runde", fontWeight = FontWeight.SemiBold, modifier = Modifier.width(56.dp))
|
||||
@@ -614,37 +691,26 @@ internal fun TournamentEditorMatchesTab(
|
||||
}
|
||||
}
|
||||
val roundLabel = m.groupRound?.toString() ?: m.round.takeIf { it.isNotBlank() } ?: "-"
|
||||
val groupLabel = m.groupId?.let { "${tr("tournaments.group", "Gruppe")} $it" } ?: "-"
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val groupNumber = m.groupId?.let { groupNumberById[it] }
|
||||
val groupLabel = groupNumber?.let { "${tr("tournaments.group", "Gruppe")} $it" } ?: "-"
|
||||
val rowBg = when {
|
||||
m.isFinished == true -> MaterialTheme.colors.onSurface.copy(alpha = 0.06f)
|
||||
m.isActive == true -> Color(0xFFFFF3E0)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(rowBg)
|
||||
.padding(vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(roundLabel, modifier = Modifier.width(56.dp))
|
||||
Text(groupLabel, modifier = Modifier.width(96.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("${displayNameFromPlayerJson(m.player1)} – ${displayNameFromPlayerJson(m.player2)}", style = MaterialTheme.typography.body2)
|
||||
val results = m.tournamentResults
|
||||
if (!results.isNullOrEmpty()) {
|
||||
results.sortedBy { it.set }.forEach { r ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val a = r.pointsPlayer1?.toString() ?: "-"
|
||||
val b = r.pointsPlayer2?.toString() ?: "-"
|
||||
Text("${r.set}: $a:$b", style = MaterialTheme.typography.caption)
|
||||
if (m.isFinished != true) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
IconButton(onClick = { confirmDelete = Pair(m.id, r.set) }, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = "Löschen",
|
||||
tint = MaterialTheme.colors.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(0.7f)) {
|
||||
if (m.isFinished == true) {
|
||||
Text(formatMatchSets(m))
|
||||
Text((m.result ?: "—") + " ✓")
|
||||
} else if (m.result == "BYE") {
|
||||
Text("BYE")
|
||||
} else {
|
||||
@@ -676,9 +742,47 @@ internal fun TournamentEditorMatchesTab(
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(m.tournamentResults?.size?.toString() ?: "0", modifier = Modifier.width(72.dp))
|
||||
Column(modifier = Modifier.width(72.dp)) {
|
||||
val results = m.tournamentResults
|
||||
if (!results.isNullOrEmpty()) {
|
||||
Text(formatMatchSets(m))
|
||||
results.sortedBy { it.set }.forEach { r ->
|
||||
val a = r.pointsPlayer1?.let { kotlin.math.abs(it).toString() } ?: "-"
|
||||
val b = r.pointsPlayer2?.let { kotlin.math.abs(it).toString() } ?: "-"
|
||||
Text("${r.set}: $a:$b", style = MaterialTheme.typography.caption)
|
||||
}
|
||||
} else {
|
||||
Text(m.tournamentResults?.size?.toString() ?: "0")
|
||||
}
|
||||
}
|
||||
// Table number editor
|
||||
Column(modifier = Modifier.width(96.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
var tableInput by remember(m.id) { mutableStateOf(m.tableNumber?.toString() ?: "") }
|
||||
var savingTable by remember(m.id) { mutableStateOf(false) }
|
||||
OutlinedTextField(
|
||||
value = tableInput,
|
||||
onValueChange = { tableInput = it.filter { ch -> ch.isDigit() || ch == '-' } },
|
||||
singleLine = true,
|
||||
label = { Text(tr("tournaments.tableShort", "Tisch")) },
|
||||
modifier = Modifier.width(72.dp).onFocusChanged { fs ->
|
||||
if (!fs.isFocused && !savingTable) {
|
||||
val nt = tableInput.toIntOrNull()
|
||||
savingTable = true
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.setMatchTable(clubId, tournamentId, m.id, TournamentMatchTableBody(tableNumber = nt))
|
||||
}
|
||||
}.onFailure { onError(it.message) }
|
||||
savingTable = false
|
||||
onReload()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
// actions column
|
||||
Column(modifier = Modifier.width(96.dp)) {
|
||||
Column(modifier = Modifier.width(120.dp)) {
|
||||
if (m.isFinished != true) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TextButton(onClick = {
|
||||
@@ -692,6 +796,17 @@ internal fun TournamentEditorMatchesTab(
|
||||
onReload()
|
||||
}
|
||||
}) { Text(tr("tournaments.finishMatchShort", "Fertig")) }
|
||||
// start/stop match
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.setMatchActive(clubId, tournamentId, m.id, TournamentMatchActiveBody(isActive = true))
|
||||
}
|
||||
}.onFailure { onError(it.message) }
|
||||
onReload()
|
||||
}
|
||||
}) { Text(tr("tournaments.startMatch", "Starten")) }
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = {
|
||||
@@ -705,6 +820,19 @@ internal fun TournamentEditorMatchesTab(
|
||||
}
|
||||
}) { Text(tr("tournaments.correct", "Korrigieren")) }
|
||||
}
|
||||
// Stop button shown regardless (to allow stopping active matches)
|
||||
if (m.isActive == true) {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.setMatchActive(clubId, tournamentId, m.id, TournamentMatchActiveBody(isActive = false))
|
||||
}
|
||||
}.onFailure { onError(it.message) }
|
||||
onReload()
|
||||
}
|
||||
}) { Text(tr("tournaments.stopMatch", "Stoppen")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
@@ -740,6 +868,42 @@ internal fun TournamentEditorMatchesTab(
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showDistributedDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDistributedDialog = false },
|
||||
title = { Text(tr("tournaments.distributeTablesResult", "Tischverteilung")) },
|
||||
text = {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (distributedMatches.isEmpty()) {
|
||||
Text(distributedMessage ?: tr("tournaments.tablesDistributed", "Tische wurden verteilt."))
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Text(tr("tournaments.playerA", "Spieler A"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.playerB", "Spieler B"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.tableShort", "Tisch"), modifier = Modifier.width(48.dp), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Divider()
|
||||
LazyColumn {
|
||||
items(distributedMatches) { m ->
|
||||
val name1 = displayNameFromPlayerJson(m.player1)
|
||||
val name2 = displayNameFromPlayerJson(m.player2)
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(name1, modifier = Modifier.weight(1f))
|
||||
Text("–", modifier = Modifier.width(16.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
Text(name2, modifier = Modifier.weight(1f))
|
||||
Text(m.tableNumber?.toString() ?: "-", modifier = Modifier.width(48.dp))
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showDistributedDialog = false }) { Text(tr("common.ok", "OK")) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,8 +933,13 @@ private fun MatchResultRow(
|
||||
if (setsText.isNotBlank()) {
|
||||
Text(setsText, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
val resultDisplay = if (!m.tournamentResults.isNullOrEmpty()) {
|
||||
formatMatchSets(m) + if (m.isFinished == true) " ✓" else ""
|
||||
} else {
|
||||
(m.result ?: "—") + if (m.isFinished == true) " ✓" else ""
|
||||
}
|
||||
Text(
|
||||
"${tr("tournaments.result", "Ergebnis")}: ${m.result ?: "—"} ${if (m.isFinished == true) "✓" else ""}",
|
||||
"${tr("tournaments.result", "Ergebnis")}: $resultDisplay",
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
if (m.isFinished != true) {
|
||||
@@ -889,13 +1058,16 @@ private fun normalizeResult(s: String): String? {
|
||||
private fun formatMatchSets(match: TournamentMatchDto): String {
|
||||
val results = match.tournamentResults.orEmpty()
|
||||
if (results.isEmpty()) return match.result ?: "—"
|
||||
return results
|
||||
.sortedBy { it.set }
|
||||
.joinToString(", ") { result ->
|
||||
val p1 = result.pointsPlayer1?.let { kotlin.math.abs(it).toString() } ?: "-"
|
||||
val p2 = result.pointsPlayer2?.let { kotlin.math.abs(it).toString() } ?: "-"
|
||||
"$p1:$p2"
|
||||
var p1Wins = 0
|
||||
var p2Wins = 0
|
||||
for (r in results.sortedBy { it.set }) {
|
||||
val a = r.pointsPlayer1?.let { kotlin.math.abs(it) }
|
||||
val b = r.pointsPlayer2?.let { kotlin.math.abs(it) }
|
||||
if (a != null && b != null) {
|
||||
if (a > b) p1Wins++ else if (b > a) p2Wins++
|
||||
}
|
||||
}
|
||||
return "${p1Wins}:${p2Wins}"
|
||||
}
|
||||
|
||||
private fun displayNameFromPlayerJson(el: JsonElement?): String {
|
||||
|
||||
@@ -256,6 +256,7 @@ internal fun InternalTournamentEditorScreen(
|
||||
matches = matches,
|
||||
winningSets = d.winningSets ?: 3,
|
||||
detail = d,
|
||||
groupsJson = groupsJson,
|
||||
tr = ::tr,
|
||||
api = api,
|
||||
scope = scope,
|
||||
|
||||
@@ -71,6 +71,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
private enum class WorkspaceTab { Overview, Competitions, Results }
|
||||
|
||||
private val OfficialPanelBorder = Color(0xFFDBE3F0)
|
||||
@@ -1482,6 +1483,8 @@ private fun ResultsTabContent(
|
||||
onReload: () -> Unit,
|
||||
onShowInfo: (String, String) -> Unit,
|
||||
) {
|
||||
|
||||
|
||||
val rows = remember(parsed, participationMap.toMap()) {
|
||||
resultsRows(parsed, participationMap.toMap(), memberNameById)
|
||||
}
|
||||
@@ -1511,6 +1514,8 @@ private fun ResultsTabContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
Reference in New Issue
Block a user