diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 7a88b1f1..eeaab077 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -2068,6 +2068,8 @@ class TournamentService { match.result = `${win}:${lose}`; // Wenn ein Match abgeschlossen wird, darf es nicht mehr als aktiv gelten match.isActive = false; + // Freigebe den Tisch, damit er für neue Zuweisungen verfügbar ist + match.tableNumber = null; await match.save(); console.log(`[finishMatch] match ${matchId} finished, result=${match.result}, isActive set to false`); @@ -2921,13 +2923,17 @@ class TournamentService { // Ermittle aktuell laufende Matches, damit wir nur Spiele verteilen, bei denen beide Spieler frei sind const activeMatches = await TournamentMatch.findAll({ where: { tournamentId, isActive: true, isFinished: false } }); - const activePlayerIds = new Set(); + + // Nur Spieler aus aktiv laufenden Matches mit einem zugewiesenen Tisch blockieren. + const occupiedPlayerIds = new Set(); activeMatches.forEach(am => { - if (am.player1Id) activePlayerIds.add(Number(am.player1Id)); - if (am.player2Id) activePlayerIds.add(Number(am.player2Id)); + if (am.tableNumber != null) { + if (am.player1Id) occupiedPlayerIds.add(Number(am.player1Id)); + if (am.player2Id) occupiedPlayerIds.add(Number(am.player2Id)); + } }); - console.log(`[distributeTables] activeMatches=${activeMatches.length} activePlayers=${[...activePlayerIds].slice(0,10).join(',')}`); + console.log(`[distributeTables] activeMatches=${activeMatches.length} occupiedPlayers=${[...occupiedPlayerIds].slice(0,10).join(',')}`); // Lade nur noch die nicht abgeschlossenen, noch nicht zugewiesenen Matches const matches = await TournamentMatch.findAll({ @@ -2937,33 +2943,59 @@ class TournamentService { console.log(`[distributeTables] candidateMatches=${matches.length}`); - let idx = 0; + // Ermittle aktuell belegte Tischnummern basierend auf laufenden Matches + const occupiedTables = new Set(); + activeMatches.forEach(am => { + if (am.tableNumber != null) occupiedTables.add(Number(am.tableNumber)); + }); + + console.log(`[distributeTables] occupiedTables=${[...occupiedTables].join(',')}`); + + // Erzeuge Liste verfügbarer Tische (1..numTables) minus belegte Tische + const availableTables = []; + for (let t = 1; t <= numTables; t++) { + if (!occupiedTables.has(t)) availableTables.push(t); + } + + if (availableTables.length === 0) { + console.log('[distributeTables] no available tables (all occupied by active matches)'); + return []; + } + const updated = []; + // Verwende `busyPlayers` (initial aus occupiedPlayerIds) und erweitere es bei Zuweisungen, + // damit ein Spieler nicht mehrere Tische in einer Verteilung bekommt. + const busyPlayers = new Set(occupiedPlayerIds); + for (const m of matches) { // Spiele mit BYE (fehlendem Spieler) überspringen if (!m.player1Id || !m.player2Id) { console.log(`[distributeTables] skipping match ${m.id} (BYE or missing player) p1=${m.player1Id} p2=${m.player2Id}`); continue; } - // Nur vergeben, wenn beide Spieler aktuell nicht in einem aktiven Match sind - if (activePlayerIds.has(Number(m.player1Id)) || activePlayerIds.has(Number(m.player2Id))) { - console.log(`[distributeTables] skipping match ${m.id} because player active p1=${m.player1Id} p2=${m.player2Id}`); + // Nur vergeben, wenn beide Spieler aktuell keinen Tisch belegen (busyPlayers) + if (busyPlayers.has(Number(m.player1Id)) || busyPlayers.has(Number(m.player2Id))) { + console.log(`[distributeTables] skipping match ${m.id} because player occupies table p1=${m.player1Id} p2=${m.player2Id}`); continue; } - const assign = (idx % numTables) + 1; + // Wenn keine freien Tische mehr vorhanden, abbrechen + if (availableTables.length === 0) { + console.log('[distributeTables] no more available tables to assign'); + break; + } + + // Nimm den nächsten freien Tisch (FIFO) und markiere ihn als belegt + const assign = availableTables.shift(); m.tableNumber = assign; - // Markiere das Spiel als gestartet (läuft) - m.isActive = true; + m.isActive = true; // Markiere Spiel als gestartet await m.save(); updated.push(m.toJSON ? m.toJSON() : m); - console.log(`[distributeTables] assigned match ${m.id} -> table ${assign}`); - // Spieler gelten nun als aktiv - damit kein weiteres Spiel für sie zugewiesen wird - activePlayerIds.add(Number(m.player1Id)); - activePlayerIds.add(Number(m.player2Id)); - idx++; + // Markiere Spieler als busy, damit sie in dieser Verteilung keinen weiteren Tisch bekommen + busyPlayers.add(Number(m.player1Id)); + busyPlayers.add(Number(m.player2Id)); } console.log(`[distributeTables] finished: assignedCount=${updated.length}`); diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt index 714ea1ca..6fab9de3 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/InternalTournamentEditorDetailTabs.kt @@ -94,6 +94,8 @@ internal fun TournamentEditorClassesTab( var showAdd by remember { mutableStateOf(false) } var newName by remember { mutableStateOf("") } var newDoubles by remember { mutableStateOf(false) } + var showDistributedDialog by remember { mutableStateOf(false) } + var distributedMatches by remember { mutableStateOf>(emptyList()) } if (showAdd) { AlertDialog( onDismissRequest = { showAdd = false }, @@ -142,15 +144,56 @@ internal fun TournamentEditorClassesTab( OutlinedButton(onClick = { scope.launch { runCatching { - withContext(Dispatchers.IO) { + 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 } + showDistributedDialog = true + } }.onFailure { onError(it.message) } onReload() } }, modifier = Modifier.weight(1f)) { Text(tr("tournaments.distributeTables", "Freie Tische verteilen")) } + + if (showDistributedDialog) { + AlertDialog( + onDismissRequest = { showDistributedDialog = false }, + 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)) + } + Divider() + } + } + } + }, + confirmButton = { + TextButton(onClick = { showDistributedDialog = false }) { Text(tr("common.ok", "OK")) } + } + ) + } } Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) { classes.forEach { c -> @@ -222,6 +265,30 @@ private fun ClassRowEditor( } } +private fun extractPlayerName(playerElem: JsonElement?): String { + try { + if (playerElem == null || playerElem is JsonNull) return "-" + val obj = playerElem.jsonObject + // Try member + val member = obj["member"]?.jsonObject + if (member != null) { + val first = member["firstName"]?.jsonPrimitive?.contentOrNull ?: "" + val last = member["lastName"]?.jsonPrimitive?.contentOrNull ?: "" + val name = "$first $last".trim() + if (name.isNotBlank()) return name + } + // Try direct fields + val first = obj["firstName"]?.jsonPrimitive?.contentOrNull + val last = obj["lastName"]?.jsonPrimitive?.contentOrNull + if (first != null || last != null) return listOfNotNull(first, last).joinToString(" ") + // Fallback to id + val id = obj["id"]?.jsonPrimitive?.intOrNull + return if (id != null) "#${id}" else "-" + } catch (e: Exception) { + return "-" + } +} + @Composable internal fun TournamentEditorParticipantsTab( clubId: Int, diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/TournamentsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/TournamentsApi.kt index cf9efa3e..d7744bfa 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/TournamentsApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/TournamentsApi.kt @@ -156,10 +156,10 @@ class TournamentsApi( } } - suspend fun distributeTables(body: TournamentClubTournamentBody) { - client.http.post("/api/tournament/matches/distribute") { + suspend fun distributeTables(body: TournamentClubTournamentBody): de.tsschulz.tt_tagebuch.shared.api.models.DistributeTablesResponseDto { + return client.http.post("/api/tournament/matches/distribute") { setBody(body) - } + }.body() } suspend fun getGroups(clubId: Int, tournamentId: Int): JsonElement { diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/TournamentEditorDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/TournamentEditorDtos.kt index 026b1ff4..b0816ca3 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/TournamentEditorDtos.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/TournamentEditorDtos.kt @@ -284,6 +284,12 @@ data class TournamentMatchDto( val tournamentResults: List? = null, ) +@Serializable +data class DistributeTablesResponseDto( + val updated: List = emptyList(), + val message: String? = null, +) + @Serializable data class TournamentAddMatchResultBody( val clubId: Int,