feat: Implement friendly match management features
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:
Torsten Schulz (local)
2026-05-18 00:43:42 +02:00
parent 040e758044
commit 5dfdcb63bc
16 changed files with 1551 additions and 87 deletions

View File

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

View File

@@ -256,6 +256,7 @@ internal fun InternalTournamentEditorScreen(
matches = matches,
winningSets = d.winningSets ?: 3,
detail = d,
groupsJson = groupsJson,
tr = ::tr,
api = api,
scope = scope,

View File

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