From 8d1bce2ff9e3881fb07d2ae0380102fb1d84fa44 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 10 Jun 2026 08:08:31 +0200 Subject: [PATCH] Fixed tournament - groups in end round and place 3 --- backend/services/tournamentService.js | 296 +++++++++++++----- .../tournament/TournamentConfigTab.vue | 40 +-- .../tournament/TournamentGroupsTab.vue | 2 +- .../tournament/TournamentPlacementsTab.vue | 34 +- .../tournament/TournamentResultsTab.vue | 34 +- frontend/src/i18n/locales/de-extended.json | 2 + frontend/src/i18n/locales/de.json | 2 + frontend/src/i18n/locales/en-AU.json | 2 + frontend/src/i18n/locales/en-GB.json | 2 + frontend/src/i18n/locales/en-US.json | 2 + frontend/src/i18n/locales/es.json | 2 + frontend/src/i18n/locales/fil.json | 2 + frontend/src/i18n/locales/fr.json | 2 + frontend/src/i18n/locales/it.json | 2 + frontend/src/i18n/locales/ja.json | 2 + frontend/src/i18n/locales/pl.json | 2 + frontend/src/i18n/locales/th.json | 2 + frontend/src/i18n/locales/tl.json | 2 + frontend/src/i18n/locales/zh.json | 2 + frontend/src/views/TournamentTab.vue | 103 +++--- .../ui/InternalTournamentEditorDetailTabs.kt | 12 +- 21 files changed, 396 insertions(+), 153 deletions(-) diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 5a3cf9e7..8ee41127 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -124,6 +124,86 @@ function compareKnockoutEntrants(a, b) { const THIRD_PLACE_ROUND = 'Spiel um Platz 3'; class TournamentService { + async createRoundRobinMatchesForGroups(tournamentId, groups, stageId = null) { + for (const group of groups) { + const internalMembers = await TournamentMember.findAll({ where: { groupId: group.id } }); + const externalMembers = await ExternalTournamentParticipant.findAll({ where: { groupId: group.id } }); + + const allGroupMembers = [ + ...internalMembers.map(m => ({ id: m.id, key: `internal-${m.id}` })), + ...externalMembers.map(m => ({ id: m.id, key: `external-${m.id}` })), + ]; + if (allGroupMembers.length < 2) continue; + + const memberMap = new Map(allGroupMembers.map(m => [m.key, m])); + const rounds = this.generateRoundRobinSchedule(allGroupMembers.map(m => ({ id: m.key }))); + for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) { + for (const [p1Key, p2Key] of rounds[roundIndex]) { + const p1 = memberMap.get(p1Key); + const p2 = memberMap.get(p2Key); + if (!p1 || !p2) continue; + await TournamentMatch.create({ + tournamentId, + stageId, + groupId: group.id, + round: 'group', + player1Id: p1.id, + player2Id: p2.id, + groupRound: roundIndex + 1, + classId: group.classId, + }); + } + } + } + } + + async assignEntrantsToStageGroups(tournamentId, items, groups, classId) { + if (!groups.length || !items.length) return; + const orderedItems = [...items].sort(compareKnockoutEntrants); + const assignments = groups.map(() => []); + orderedItems.forEach((item, index) => { + assignments[index % groups.length].push(item); + }); + + for (let groupIndex = 0; groupIndex < assignments.length; groupIndex++) { + const group = groups[groupIndex]; + for (const item of assignments[groupIndex]) { + if (item.isExternal) { + const original = await ExternalTournamentParticipant.findByPk(Number(item.id)); + if (!original) continue; + await ExternalTournamentParticipant.create({ + tournamentId, + groupId: group.id, + firstName: original.firstName, + lastName: original.lastName, + club: original.club, + email: original.email, + address: original.address, + birthDate: original.birthDate, + gender: original.gender, + seeded: original.seeded || false, + classId, + outOfCompetition: original.outOfCompetition || false, + gaveUp: original.gaveUp || false, + }); + } else { + const clubMemberId = Number(item.clubMemberId ?? item.member?.id ?? item.id); + const member = Number.isFinite(clubMemberId) ? await Member.findByPk(clubMemberId) : null; + if (!member) continue; + await TournamentMember.create({ + tournamentId, + groupId: group.id, + clubMemberId, + seeded: item.seeded || false, + classId, + outOfCompetition: item.outOfCompetition || false, + gaveUp: item.gaveUp || false, + }); + } + } + } + } + // -------- Multi-Stage (Runden) V1 -------- async getTournamentStages(userToken, clubId, tournamentId) { await checkAccess(userToken, clubId); @@ -257,24 +337,35 @@ class TournamentService { } const config = normalizeJsonConfig(adv.config, 'Advancement-Konfiguration'); - const pools = Array.isArray(config.pools) ? config.pools : []; + let pools = Array.isArray(config.pools) ? config.pools : []; if (pools.length === 0) { - const keys = Object.keys(config); - throw new Error( - `Advancement-Konfiguration ist leer (keine Pools). ` + - `advancementId=${adv.id}, fromStageId=${fromStage.id}, toStageId=${toStage.id}, ` + - `configKeys=${keys.join(',') || '(none)'}` - ); + const advancingCount = Math.max(1, Number(toStage.advancingPerGroup || t.advancingPerGroup || 2)); + const fromPlaces = Array.from({ length: advancingCount }, (_, index) => index + 1); + pools = [{ + fromPlaces, + target: toStage.type === 'knockout' + ? { type: 'knockout', singleField: true } + : { type: 'groups', groupCount: Math.max(1, Number(toStage.numberOfGroups || 1)) }, + }]; } - // Cleanup Stage2 + // Cleanup Zielrunde inklusive dort duplizierter Teilnehmer. + const existingTargetGroups = await TournamentGroup.findAll({ where: { tournamentId, stageId: toStage.id } }); + const existingTargetGroupIds = existingTargetGroups.map(g => g.id); await TournamentMatch.destroy({ where: { tournamentId, stageId: toStage.id } }); + if (existingTargetGroupIds.length > 0) { + await TournamentMember.destroy({ where: { tournamentId, groupId: { [Op.in]: existingTargetGroupIds } } }); + await ExternalTournamentParticipant.destroy({ where: { tournamentId, groupId: { [Op.in]: existingTargetGroupIds } } }); + } await TournamentGroup.destroy({ where: { tournamentId, stageId: toStage.id } }); // Stage1 Gruppen mit Teilnehmern (Ranking aus existierender Logik) const stage1Groups = await this.getGroupsWithParticipants(userToken, clubId, tournamentId); - const relevantStage1Groups = stage1Groups.filter(g => (g.stageId == null) || (g.stageId === fromStage.id)); - if (relevantStage1Groups.length === 0) throw new Error('Keine Gruppen in Runde 1 gefunden'); + const relevantStage1Groups = stage1Groups.filter(g => { + if (Number(fromStage.index) === 1 && g.stageId == null) return true; + return Number(g.stageId) === Number(fromStage.id); + }); + if (relevantStage1Groups.length === 0) throw new Error('Keine Gruppen in der Ausgangsrunde gefunden'); const perGroupRanked = relevantStage1Groups.map(g => ({ groupId: g.groupId, @@ -370,10 +461,8 @@ class TournamentService { [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } - // WICHTIG: Um bestehende Gruppen (Stage 1) nicht zu überschreiben, - // nehmen wir hier KEINE groupId-Zuordnung für Teilnehmer vor. - // Die Stage-2-Gruppen werden angelegt und durch Matches befüllt, - // ohne den groupId-Wert der Teilnehmer zu verändern. + await this.assignEntrantsToStageGroups(tournamentId, shuffled, poolGroups, poolClassId); + await this.createRoundRobinMatchesForGroups(tournamentId, poolGroups, toStage.id); createdGroups.push(...poolGroups); } else if (target.type === 'knockout') { @@ -406,22 +495,6 @@ class TournamentService { const bracketSize = nextPowerOfTwo(entrants.length); const byes = bracketSize - entrants.length; const roundName = getRoundName(bracketSize); - if (wantsThirdPlace && bracketSize >= 4) { - // Platzhalter-Match; Teilnehmer werden später nach den Halbfinals gesetzt. - await TournamentMatch.create({ - tournamentId, - stageId: toStage.id, - groupId: containerGroup.id, - classId: poolClassId, - groupRound: null, - round: THIRD_PLACE_ROUND, - player1Id: null, - player2Id: null, - isFinished: false, - isActive: false, - result: null, - }); - } // Erzeuge zunächst BYE‑Matches für die ersten 'byes' Teilnehmer let remaining = [...entrants]; if (byes > 0) { @@ -540,22 +613,6 @@ class TournamentService { const reversedSecondHalf = [...secondHalf].reverse(); // Umgekehrte Reihenfolge für Pairing const roundName = getRoundName(bracketSize); - if (thirdPlace && bracketSize >= 4) { - await TournamentMatch.create({ - tournamentId, - stageId: toStage.id, - groupId: containerGroup.id, - classId, - groupRound: null, - round: THIRD_PLACE_ROUND, - player1Id: null, - player2Id: null, - isFinished: false, - isActive: false, - result: null, - }); - } - // BYE‑Matches zuerst (für die ersten Teilnehmer, wenn nötig) if (byes > 0) { // Die besten Spieler bekommen BYEs @@ -769,7 +826,12 @@ class TournamentService { if (classId !== null) { whereClause.classId = classId; } - return await TournamentMember.findAll({ + const stageGroups = await TournamentGroup.findAll({ + where: { tournamentId, stageId: { [Op.ne]: null } }, + attributes: ['id'], + }); + const stageGroupIds = new Set(stageGroups.map(g => Number(g.id))); + const participants = await TournamentMember.findAll({ where: whereClause, include: [{ model: Member, @@ -778,6 +840,7 @@ class TournamentService { }], order: [[{ model: Member, as: 'member' }, 'firstName', 'ASC']] }); + return participants.filter(p => !p.groupId || !stageGroupIds.has(Number(p.groupId))); } // 5. Modus setzen (Gruppen / KO‑Runde) @@ -1545,6 +1608,7 @@ class TournamentService { // Filtere nur KO‑Container‑Gruppen heraus: erkenne sie über Stage‑Typen. // Vorrunden‑Gruppen bleiben sichtbar, auch wenn (noch) keine Matches existieren. const stages = await TournamentStage.findAll({ where: { tournamentId } }); + const stageById = new Map(stages.map(s => [Number(s.id), s])); const knockoutStageIds = new Set(stages.filter(s => s.type === 'knockout').map(s => s.id)); groups = groups.filter(g => !(g.stageId && knockoutStageIds.has(g.stageId))); @@ -1634,6 +1698,7 @@ class TournamentService { for (const tm of g.tournamentGroupMembers || []) { stats[tm.id] = { id: tm.id, + clubMemberId: tm.clubMemberId, name: `${tm.member.firstName} ${tm.member.lastName}`, seeded: tm.seeded || false, isExternal: false, @@ -1870,10 +1935,22 @@ class TournamentService { }; }); + const stage = g.stageId ? stageById.get(Number(g.stageId)) : null; + const sameStageGroups = classGroups.filter(other => (other.stageId ?? null) === (g.stageId ?? null)); + const stageGroupIndex = sameStageGroups.findIndex(other => Number(other.id) === Number(g.id)); + const groupNumber = stageGroupIndex >= 0 ? stageGroupIndex + 1 : idx + 1; + const stageLabel = stage + ? (stage.name || (Number(stage.index) === 3 ? 'Endrunde' : 'Zwischenrunde')) + : null; + result.push({ groupId: g.id, classId: g.classId, - groupNumber: idx + 1, // Nummer innerhalb der Klasse + stageId: g.stageId ?? null, + stageIndex: stage?.index ?? null, + stageName: stageLabel, + groupNumber, + groupLabel: stageLabel ? `${stageLabel}-Gruppe ${groupNumber}` : null, participants: participantsWithPosition }); }); @@ -2150,9 +2227,20 @@ class TournamentService { } }); - if (!existingThirdPlace) { + if (existingThirdPlace) { + if ((existingThirdPlace.player1Id == null || existingThirdPlace.player1Id === 0) && (existingThirdPlace.player2Id == null || existingThirdPlace.player2Id === 0)) { + existingThirdPlace.player1Id = losers[0]; + existingThirdPlace.player2Id = losers[1]; + existingThirdPlace.stageId = existingThirdPlace.stageId || match.stageId || null; + existingThirdPlace.groupId = existingThirdPlace.groupId || match.groupId || null; + existingThirdPlace.isActive = true; + await existingThirdPlace.save(); + } + } else { await TournamentMatch.create({ tournamentId, + stageId: match.stageId || null, + groupId: match.groupId || null, round: THIRD_PLACE_ROUND, player1Id: losers[0], player2Id: losers[1], @@ -2170,7 +2258,7 @@ class TournamentService { // Gilt nur für neue Stage-KO-Matches (stageId + groupId gesetzt). // Wichtig: Halbfinal-Rundennamen können Suffixe haben (z.B. "Halbfinale (3)"). if (match.stageId && match.groupId && match.round && String(match.round).includes('Halbfinale')) { - const thirdPlaceMatch = await TournamentMatch.findOne({ + let thirdPlaceMatch = await TournamentMatch.findOne({ where: { tournamentId, stageId: match.stageId, @@ -2179,25 +2267,37 @@ class TournamentService { } }); - if (thirdPlaceMatch) { - const semifinals = await TournamentMatch.findAll({ - where: { - tournamentId, - stageId: match.stageId, - groupId: match.groupId, - } - }); + const semifinals = await TournamentMatch.findAll({ + where: { + tournamentId, + stageId: match.stageId, + groupId: match.groupId, + } + }); - const semiMatches = semifinals.filter(m => m.round && String(m.round).includes('Halbfinale')); + const semiMatches = semifinals.filter(m => m.round && String(m.round).includes('Halbfinale')); + const finishedSemis = semiMatches.filter(m => m.isFinished && m.result); + if (finishedSemis.length >= 2) { + const losers = finishedSemis + .map(getLoserId) + .filter(id => Number.isFinite(id) && id > 0); - const finishedSemis = semiMatches.filter(m => m.isFinished && m.result); - if (finishedSemis.length >= 2) { - const losers = finishedSemis - .map(getLoserId) - .filter(id => Number.isFinite(id) && id > 0); - - // Nur setzen, wenn wir genau 2 Verlierer haben und das Match noch "leer" ist. - if (losers.length === 2 && (thirdPlaceMatch.player1Id == null || thirdPlaceMatch.player1Id === 0) && (thirdPlaceMatch.player2Id == null || thirdPlaceMatch.player2Id === 0)) { + if (losers.length === 2) { + if (!thirdPlaceMatch) { + thirdPlaceMatch = await TournamentMatch.create({ + tournamentId, + stageId: match.stageId, + groupId: match.groupId, + classId: match.classId, + groupRound: null, + round: THIRD_PLACE_ROUND, + player1Id: losers[0], + player2Id: losers[1], + isFinished: false, + isActive: true, + result: null, + }); + } else if ((thirdPlaceMatch.player1Id == null || thirdPlaceMatch.player1Id === 0) && (thirdPlaceMatch.player2Id == null || thirdPlaceMatch.player2Id === 0)) { thirdPlaceMatch.player1Id = losers[0]; thirdPlaceMatch.player2Id = losers[1]; thirdPlaceMatch.isActive = true; @@ -2208,14 +2308,17 @@ class TournamentService { } // Prüfe, ob alle Matches dieser Runde UND Klasse abgeschlossen sind + const roundWhere = { tournamentId, round: match.round, classId: match.classId }; + if (match.stageId) roundWhere.stageId = match.stageId; + if (match.groupId) roundWhere.groupId = match.groupId; const allFinished = await TournamentMatch.count({ - where: { tournamentId, round: match.round, isFinished: false, classId: match.classId } + where: { ...roundWhere, isFinished: false } }) === 0; if (allFinished) { - // Lade alle Matches dieser Runde UND Klasse + // Lade alle Matches dieser Runde UND Klasse innerhalb desselben KO-Felds const sameRound = await TournamentMatch.findAll({ - where: { tournamentId, round: match.round, classId: match.classId } + where: roundWhere }); // Gruppiere nach Klasse @@ -2262,6 +2365,8 @@ class TournamentService { } await TournamentMatch.create({ tournamentId, + stageId: match.stageId || null, + groupId: match.groupId || null, round: nextName, player1Id: p1, player2Id: p2, @@ -3053,15 +3158,50 @@ class TournamentService { async resetKnockout(userToken, clubId, tournamentId, classId = null) { await checkAccess(userToken, clubId); - // lösche alle Matches außer Gruppenphase - const where = { + + const stages = await TournamentStage.findAll({ where: { tournamentId } }); + const nonPreliminaryStageIds = stages + .filter(stage => Number(stage.index) !== 1) + .map(stage => Number(stage.id)); + + const stageGroupWhere = { tournamentId }; + if (nonPreliminaryStageIds.length > 0) { + stageGroupWhere.stageId = { [Op.in]: nonPreliminaryStageIds }; + } else { + stageGroupWhere.stageId = { [Op.ne]: null }; + } + if (classId != null) { + stageGroupWhere.classId = Number(classId); + } + + const stageGroups = await TournamentGroup.findAll({ where: stageGroupWhere }); + const stageGroupIds = stageGroups.map(group => Number(group.id)); + + const knockoutWhere = { tournamentId, round: { [Op.ne]: "group" } }; if (classId != null) { - where.classId = Number(classId); + knockoutWhere.classId = Number(classId); + } + await TournamentMatch.destroy({ where: knockoutWhere }); + + if (stageGroupIds.length > 0) { + const stageMatchWhere = { + tournamentId, + [Op.or]: [ + { groupId: { [Op.in]: stageGroupIds } }, + { stageId: { [Op.in]: nonPreliminaryStageIds } }, + ], + }; + if (classId != null) { + stageMatchWhere.classId = Number(classId); + } + await TournamentMatch.destroy({ where: stageMatchWhere }); + await TournamentMember.destroy({ where: { tournamentId, groupId: { [Op.in]: stageGroupIds } } }); + await ExternalTournamentParticipant.destroy({ where: { tournamentId, groupId: { [Op.in]: stageGroupIds } } }); + await TournamentGroup.destroy({ where: { id: { [Op.in]: stageGroupIds } } }); } - await TournamentMatch.destroy({ where }); } // Externe Teilnehmer hinzufügen @@ -3143,10 +3283,16 @@ class TournamentService { if (classId !== null) { whereClause.classId = classId; } - return await ExternalTournamentParticipant.findAll({ + const stageGroups = await TournamentGroup.findAll({ + where: { tournamentId, stageId: { [Op.ne]: null } }, + attributes: ['id'], + }); + const stageGroupIds = new Set(stageGroups.map(g => Number(g.id))); + const participants = await ExternalTournamentParticipant.findAll({ where: whereClause, order: [['firstName', 'ASC'], ['lastName', 'ASC']] }); + return participants.filter(p => !p.groupId || !stageGroupIds.has(Number(p.groupId))); } // Externe Teilnehmer löschen diff --git a/frontend/src/components/tournament/TournamentConfigTab.vue b/frontend/src/components/tournament/TournamentConfigTab.vue index eedd2e50..50b1d9ce 100644 --- a/frontend/src/components/tournament/TournamentConfigTab.vue +++ b/frontend/src/components/tournament/TournamentConfigTab.vue @@ -563,16 +563,34 @@ export default { }) .filter(p => p.fromPlaces.length > 0); }, + defaultPoolsForTarget(targetType, groupCount = 1, thirdPlace = false) { + return [{ + fromPlaces: [1, 2], + target: targetType === 'knockout' + ? { type: 'knockout', singleField: true, thirdPlace } + : { type: 'groups', groupCount: Math.max(1, Number(groupCount || 1)) } + }]; + }, buildPayload() { - const pools12 = this.stageConfig.useIntermediateStage + let pools12 = this.stageConfig.useIntermediateStage ? this.buildPoolsPayload(this.stageConfig.pools12, this.stageConfig.stage2GroupCount || 2, false) : []; - const poolsFinal = this.buildPoolsPayload( + if (this.stageConfig.useIntermediateStage && pools12.length === 0) { + pools12 = this.defaultPoolsForTarget(this.stageConfig.stage2Type, this.stageConfig.stage2GroupCount || 2, false); + } + let poolsFinal = this.buildPoolsPayload( this.stageConfig.poolsFinal, this.stageConfig.finalStageGroupCount || 1, true, this.stageConfig.finalStageThirdPlace === true ); + if (poolsFinal.length === 0) { + poolsFinal = this.defaultPoolsForTarget( + this.stageConfig.finalStageType, + this.stageConfig.finalStageGroupCount || 1, + this.stageConfig.finalStageThirdPlace === true + ); + } const stages = [ { index: 1, type: 'groups', name: 'Vorrunde' }, @@ -637,15 +655,6 @@ export default { try { const { stages, advancements } = this.buildPayload(); - // Validierung: Für jeden Übergang müssen Pools vorhanden sein - for (const adv of advancements) { - const hasPools = Array.isArray(adv?.config?.pools) && adv.config.pools.length > 0; - if (!hasPools) { - const label = `${adv.fromStageIndex}→${adv.toStageIndex}`; - throw new Error(`Bitte mindestens eine Pool-Regel für ${label} anlegen (z.B. Plätze 1,2).`); - } - } - const res = await apiClient.put('/tournament/stages', { clubId: Number(this.clubId), tournamentId: Number(this.tournamentId), @@ -684,13 +693,8 @@ export default { clubId: Number(this.clubId), tournamentId: Number(this.tournamentId) }; - if (from?.stageId && to?.stageId) { - payload.fromStageId = from.stageId; - payload.toStageId = to.stageId; - } else { - payload.fromStageIndex = Number(fromStageIndex); - payload.toStageIndex = Number(toStageIndex); - } + payload.fromStageIndex = Number(fromStageIndex); + payload.toStageIndex = Number(toStageIndex); const res = await apiClient.post('/tournament/stages/advance', payload); if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Erstellen der Runde'); diff --git a/frontend/src/components/tournament/TournamentGroupsTab.vue b/frontend/src/components/tournament/TournamentGroupsTab.vue index 4f81ac19..005abc31 100644 --- a/frontend/src/components/tournament/TournamentGroupsTab.vue +++ b/frontend/src/components/tournament/TournamentGroupsTab.vue @@ -68,7 +68,7 @@
-

{{ $t('tournaments.groupNumber') }} {{ group.groupNumber }}

+

{{ group.groupLabel || ($t('tournaments.groupNumber') + ' ' + group.groupNumber) }}

diff --git a/frontend/src/components/tournament/TournamentPlacementsTab.vue b/frontend/src/components/tournament/TournamentPlacementsTab.vue index d85c9ba7..a50831a7 100644 --- a/frontend/src/components/tournament/TournamentPlacementsTab.vue +++ b/frontend/src/components/tournament/TournamentPlacementsTab.vue @@ -53,7 +53,7 @@
-
{{ $t('tournaments.group') }} {{ g.groupNumber }}
+
{{ g.groupLabel || ($t('tournaments.group') + ' ' + g.groupNumber) }}
@@ -401,23 +401,53 @@ export default { byClass[classKey] = (byClass[classKey] || []).sort((a, b) => Number(a.position) - Number(b.position)); }); + const finalStageGroups = this.finalStageGroups; + finalStageGroups.forEach(group => { + const classKey = group.classId != null ? String(group.classId) : 'null'; + const cid = group.classId == null ? null : Number(group.classId); + const rankings = this.groupRankings[group.groupId] || []; + if (rankings.length === 0) return; + byClass[classKey] = rankings.map(r => ({ + position: r.position, + member: { + id: r.clubMemberId || r.memberId || r.id, + firstName: (r.name || '').split(' ').slice(0, -1).join(' '), + lastName: (r.name || '').split(' ').slice(-1).join(' '), + }, + displayName: r.name, + classId: cid, + isExternal: r.isExternal || false, + })).sort((a, b) => Number(a.position) - Number(b.position)); + }); + Object.keys(byClass).forEach(k => { if (!byClass[k] || byClass[k].length === 0) delete byClass[k]; }); return byClass; }, + finalStageGroups() { + const stageGroups = (this.groups || []).filter(g => g.stageId !== null && g.stageId !== undefined); + if (stageGroups.length === 0) return []; + const maxStageIndex = Math.max(...stageGroups.map(g => Number(g.stageIndex || 0))); + return stageGroups.filter(g => Number(g.stageIndex || 0) === maxStageIndex); + }, groupPlacements() { const placements = []; // Primär: aus groups + groupRankings if ((this.groups || []).length > 0) { - this.groups.forEach(group => { + this.groups + .filter(group => group.stageId === null || group.stageId === undefined) + .forEach(group => { const rankings = this.groupRankings[group.groupId] || []; if (rankings.length > 0) { placements.push({ groupId: group.groupId, groupNumber: group.groupNumber, classId: group.classId, + groupLabel: group.groupLabel || null, + stageId: group.stageId || null, + stageIndex: group.stageIndex || null, rankings: rankings.map(r => ({ id: r.id, position: r.position, diff --git a/frontend/src/components/tournament/TournamentResultsTab.vue b/frontend/src/components/tournament/TournamentResultsTab.vue index fd978295..57969ad5 100644 --- a/frontend/src/components/tournament/TournamentResultsTab.vue +++ b/frontend/src/components/tournament/TournamentResultsTab.vue @@ -7,9 +7,9 @@ :selected-date="selectedDate" @update:modelValue="$emit('update:selectedViewClass', $event)" /> -
+
-

{{ $t('tournaments.groupMatches') }}

+

{{ section.title }}

@@ -27,14 +27,14 @@
- +
{{ m.groupRound }} @@ -125,12 +125,12 @@
@@ -369,6 +369,18 @@ export default { filteredGroupMatches() { return this.filterMatchesByClass(this.groupMatches); }, + preliminaryGroupMatches() { + return this.filteredGroupMatches.filter(m => !this.isStageMatch(m)); + }, + finalGroupMatches() { + return this.filteredGroupMatches.filter(m => this.isStageMatch(m)); + }, + groupMatchSections() { + return [ + { key: 'preliminary', title: 'Vorrunde', matches: this.preliminaryGroupMatches }, + { key: 'final', title: 'Endrunde', matches: this.finalGroupMatches }, + ].filter(section => section.matches.length > 0); + }, filteredKnockoutMatches() { return this.filterMatchesByClass(this.knockoutMatches); }, @@ -398,9 +410,8 @@ export default { return result; }, numberOfGroupsForSelectedClass() { - // Zähle direkt die Gruppen für die ausgewählte Klasse - // Nur Stage 1 Gruppen (stageId null/undefined) zählen - // Und nur Gruppen mit mindestens einem Teilnehmer + // Zähle direkt die Vorrunden-Gruppen für die ausgewählte Klasse. + // Endrunden-Gruppen dürfen den Start-Button nicht erneut aktivieren. let groupsToCount = this.groups.filter(g => (!g.stageId || g.stageId === null || g.stageId === undefined) && g.participants && Array.isArray(g.participants) && g.participants.length > 0 @@ -517,6 +528,9 @@ export default { const confirmed = this.$root && this.$root.showConfirm ? await this.$root.showConfirm(title || '', message || '') : confirm(message); if (confirmed) this.$emit('delete-set', match, set); }, + isStageMatch(match) { + return match && match.stageId !== null && match.stageId !== undefined; + }, filterMatchesByClass(matches) { // Wenn keine Klasse ausgewählt ist (null), zeige alle if (this.selectedViewClass === null || this.selectedViewClass === undefined) { diff --git a/frontend/src/i18n/locales/de-extended.json b/frontend/src/i18n/locales/de-extended.json index 7e0957de..7dfdd638 100644 --- a/frontend/src/i18n/locales/de-extended.json +++ b/frontend/src/i18n/locales/de-extended.json @@ -976,7 +976,9 @@ "setDiff": "Satzdifferenz", "createMatches": "Spiele erstellen", "startKORound": "K.o.-Runde starten", + "startFinalRound": "Endrunde starten", "deleteKORound": "K.o.-Runde", + "deleteFinalRound": "Endrunde löschen", "email": "E-Mail", "forForwarding": "für Weitermeldung", "showPlayerDetails": "Spielerdetails anzeigen", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index c3cd7deb..cc27f501 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1043,7 +1043,9 @@ "setDiff": "Satzdifferenz", "createMatches": "Spiele erstellen", "startKORound": "K.o.-Runde starten", + "startFinalRound": "Endrunde starten", "deleteKORound": "K.o.-Runde", + "deleteFinalRound": "Endrunde löschen", "email": "E-Mail", "forForwarding": "für Weitermeldung", "showPlayerDetails": "Spielerdetails anzeigen", diff --git a/frontend/src/i18n/locales/en-AU.json b/frontend/src/i18n/locales/en-AU.json index 6dcbd6b8..038e28ea 100644 --- a/frontend/src/i18n/locales/en-AU.json +++ b/frontend/src/i18n/locales/en-AU.json @@ -1027,7 +1027,9 @@ "setDiff": "Sentence Difference", "createMatches": "Set matches", "startKORound": "Start knockout round", + "startFinalRound": "Start final round", "deleteKORound": "Knockout", + "deleteFinalRound": "Delete final round", "email": "Email address", "forForwarding": "for forwarding", "showPlayerDetails": "View Player Details", diff --git a/frontend/src/i18n/locales/en-GB.json b/frontend/src/i18n/locales/en-GB.json index 8af833d2..77e8146e 100644 --- a/frontend/src/i18n/locales/en-GB.json +++ b/frontend/src/i18n/locales/en-GB.json @@ -1028,7 +1028,9 @@ "setDiff": "Sentence Difference", "createMatches": "Set matches", "startKORound": "Start knockout round", + "startFinalRound": "Start final round", "deleteKORound": "Knockout", + "deleteFinalRound": "Delete final round", "email": "Email address", "forForwarding": "for forwarding", "showPlayerDetails": "View Player Details", diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 669d6a61..8d75c278 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -1028,7 +1028,9 @@ "setDiff": "Sentence Difference", "createMatches": "Set matches", "startKORound": "Start knockout round", + "startFinalRound": "Start final round", "deleteKORound": "Knockout", + "deleteFinalRound": "Delete final round", "email": "Email address", "forForwarding": "for forwarding", "showPlayerDetails": "View Player Details", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 50feb934..3c0bcb76 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -1027,7 +1027,9 @@ "setDiff": "Diferencia de oración", "createMatches": "Establecer partidos", "startKORound": "Comienza la ronda eliminatoria", + "startFinalRound": "Iniciar ronda final", "deleteKORound": "Knockear", + "deleteFinalRound": "Eliminar ronda final", "email": "Dirección de correo electrónico", "forForwarding": "para reenviar", "showPlayerDetails": "Ver detalles del jugador", diff --git a/frontend/src/i18n/locales/fil.json b/frontend/src/i18n/locales/fil.json index 1cdf614d..74974814 100644 --- a/frontend/src/i18n/locales/fil.json +++ b/frontend/src/i18n/locales/fil.json @@ -1028,7 +1028,9 @@ "setDiff": "Pagkakaiba ng Pangungusap", "createMatches": "Magtakda ng mga tugma", "startKORound": "Simulan ang knockout round", + "startFinalRound": "Simulan ang final round", "deleteKORound": "Knockout", + "deleteFinalRound": "Burahin ang final round", "email": "Email address", "forForwarding": "para sa pagpapasa", "showPlayerDetails": "Tingnan ang Mga Detalye ng Manlalaro", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index f98a1b53..290e47b0 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -1026,7 +1026,9 @@ "setDiff": "Différence de phrase", "createMatches": "Définir des correspondances", "startKORound": "Commencer les huitièmes de finale", + "startFinalRound": "Démarrer la phase finale", "deleteKORound": "Assommer", + "deleteFinalRound": "Supprimer la phase finale", "email": "Adresse email", "forForwarding": "pour l'expédition", "showPlayerDetails": "Afficher les détails du joueur", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 071cfaab..c0a283ab 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -1028,7 +1028,9 @@ "setDiff": "Differenza di frase", "createMatches": "Imposta le partite", "startKORound": "Inizia il turno a eliminazione diretta", + "startFinalRound": "Avvia fase finale", "deleteKORound": "Tramortire", + "deleteFinalRound": "Elimina fase finale", "email": "Indirizzo e-mail", "forForwarding": "per l'inoltro", "showPlayerDetails": "Visualizza i dettagli del giocatore", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index ef97b016..55b30bb4 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -1028,7 +1028,9 @@ "setDiff": "文の違い", "createMatches": "マッチを設定する", "startKORound": "ノックアウトラウンドを開始する", + "startFinalRound": "決勝ラウンドを開始", "deleteKORound": "ノックアウト", + "deleteFinalRound": "決勝ラウンドを削除", "email": "電子メールアドレス", "forForwarding": "転送用", "showPlayerDetails": "プレーヤーの詳細を表示する", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 0374828e..3bfde991 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -1024,7 +1024,9 @@ "setDiff": "Różnica zdań", "createMatches": "Ustaw mecze", "startKORound": "Rozpocznij rundę pucharową", + "startFinalRound": "Rozpocznij rundę finałową", "deleteKORound": "Nokaut", + "deleteFinalRound": "Usuń rundę finałową", "email": "Adres e-mail", "forForwarding": "do przesyłania", "showPlayerDetails": "Wyświetl szczegóły gracza", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 916f09d4..9b5a3006 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -1027,7 +1027,9 @@ "setDiff": "ความแตกต่างของประโยค", "createMatches": "ตั้งค่าการแข่งขัน", "startKORound": "เริ่มรอบน็อคเอาท์", + "startFinalRound": "เริ่มรอบสุดท้าย", "deleteKORound": "น็อคเอาท์", + "deleteFinalRound": "ลบรอบสุดท้าย", "email": "ที่อยู่อีเมล", "forForwarding": "สำหรับการส่งต่อ", "showPlayerDetails": "ดูรายละเอียดผู้เล่น", diff --git a/frontend/src/i18n/locales/tl.json b/frontend/src/i18n/locales/tl.json index 3ab1f4c1..6a3ac2f3 100644 --- a/frontend/src/i18n/locales/tl.json +++ b/frontend/src/i18n/locales/tl.json @@ -1028,7 +1028,9 @@ "setDiff": "Pagkakaiba ng Pangungusap", "createMatches": "Magtakda ng mga tugma", "startKORound": "Simulan ang knockout round", + "startFinalRound": "Simulan ang final round", "deleteKORound": "Knockout", + "deleteFinalRound": "Burahin ang final round", "email": "Email address", "forForwarding": "para sa pagpapasa", "showPlayerDetails": "Tingnan ang Mga Detalye ng Manlalaro", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 8c71eb60..69dd96a3 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -1028,7 +1028,9 @@ "setDiff": "句子差异", "createMatches": "设置匹配项", "startKORound": "开始淘汰赛", + "startFinalRound": "开始决赛轮", "deleteKORound": "昏死", + "deleteFinalRound": "删除决赛轮", "email": "电子邮件", "forForwarding": "用于转发", "showPlayerDetails": "查看玩家详细信息", diff --git a/frontend/src/views/TournamentTab.vue b/frontend/src/views/TournamentTab.vue index cbc64a1e..cd21eca1 100644 --- a/frontend/src/views/TournamentTab.vue +++ b/frontend/src/views/TournamentTab.vue @@ -369,7 +369,7 @@ export default { ...mapGetters(['isAuthenticated', 'currentClub']), knockoutMatches() { - const koMatches = this.matches.filter(m => m.round !== 'group'); + const koMatches = this.matches.filter(m => m.round !== 'group' && !this.isEmptyThirdPlacePlaceholder(m)); // Sortiere nach Klasse, dann nach Runde return koMatches.sort((a, b) => { // Zuerst nach Klasse (null zuletzt) @@ -406,6 +406,16 @@ export default { }); }, + isEmptyThirdPlacePlaceholder(match) { + return match + && String(match.round || '').includes('Spiel um Platz 3') + && !match.player1Id + && !match.player2Id + && !match.player1 + && !match.player2 + && !match.result; + }, + // Computed property für aktive Gruppentabellen-Zellen activeGroupCells() { if (!this.activeMatchId) return []; @@ -866,6 +876,9 @@ export default { // kein Gruppenmodus → immer starten return true; } + if ((this.groups || []).some(g => g.stageId !== null && g.stageId !== undefined)) { + return false; + } // Gruppenmodus → prüfe Anzahl der Gruppen const totalGroups = this.getTotalNumberOfGroups; if (totalGroups <= 1) { @@ -892,8 +905,8 @@ export default { }, canResetKnockout() { - // Zeige den Löschen‑Button, sobald KO‑Matches existieren - return this.knockoutMatches.length > 0; + return this.knockoutMatches.length > 0 + || (this.groups || []).some(g => g.stageId !== null && g.stageId !== undefined); }, }, watch: { @@ -1304,7 +1317,12 @@ export default { `/tournament/matches/${this.currentClub}/${this.selectedDate}` ); const grpMap = this.groups.reduce((m, g) => { - m[g.groupId] = g.groupNumber; + m[g.groupId] = { + groupNumber: g.groupNumber, + groupLabel: g.groupLabel || null, + stageId: g.stageId || null, + stageName: g.stageName || null + }; return m; }, {}); @@ -1315,12 +1333,16 @@ export default { // Stelle sicher, dass groupRound vorhanden ist (kann als group_round vom Backend kommen) const groupRound = m.groupRound || m.group_round || 0; - const groupNumber = grpMap[matchGroupId] || 0; + const groupInfo = grpMap[matchGroupId] || {}; + const groupNumber = groupInfo.groupNumber || 0; return { ...m, groupId: matchGroupId, groupNumber: groupNumber, + groupLabel: groupInfo.groupLabel || null, + stageId: m.stageId || groupInfo.stageId || null, + stageName: groupInfo.stageName || null, groupRound: groupRound, // Stelle sicher, dass groupRound gesetzt ist resultInput: '', isActive: m.isActive || false @@ -1333,7 +1355,8 @@ export default { // Setze Kollaps-Status: ausgeklappt wenn keine Spiele, eingeklappt wenn Spiele vorhanden this.showParticipants = this.matches.length === 0; - this.showKnockout = this.matches.some(m => m.round !== 'group'); + this.showKnockout = this.matches.some(m => m.round !== 'group') + || (this.groups || []).some(g => g.stageId !== null && g.stageId !== undefined); }, async loadMatches() { @@ -1342,7 +1365,12 @@ export default { `/tournament/matches/${this.currentClub}/${this.selectedDate}` ); const grpMap = this.groups.reduce((m, g) => { - m[g.groupId] = g.groupNumber; + m[g.groupId] = { + groupNumber: g.groupNumber, + groupLabel: g.groupLabel || null, + stageId: g.stageId || null, + stageName: g.stageName || null + }; return m; }, {}); @@ -1353,12 +1381,16 @@ export default { // Stelle sicher, dass groupRound vorhanden ist (kann als group_round vom Backend kommen) const groupRound = m.groupRound || m.group_round || 0; - const groupNumber = grpMap[matchGroupId] || 0; + const groupInfo = grpMap[matchGroupId] || {}; + const groupNumber = groupInfo.groupNumber || 0; return { ...m, groupId: matchGroupId, groupNumber: groupNumber, + groupLabel: groupInfo.groupLabel || null, + stageId: m.stageId || groupInfo.stageId || null, + stageName: groupInfo.stageName || null, groupRound: groupRound, resultInput: '', isActive: m.isActive || false @@ -1368,7 +1400,8 @@ export default { // Setze Kollaps-Status: ausgeklappt wenn keine Spiele, eingeklappt wenn Spiele vorhanden this.showParticipants = this.matches.length === 0; - this.showKnockout = this.matches.some(m => m.round !== 'group'); + this.showKnockout = this.matches.some(m => m.round !== 'group') + || (this.groups || []).some(g => g.stageId !== null && g.stageId !== undefined); }, async handleTournamentChanged(data) { @@ -1896,44 +1929,24 @@ export default { })) .filter(s => Number.isFinite(s.stageIndex)); - // Ermittle die Reihenfolge der Stages + // Ermittle die Reihenfolge der Stages und führe genau den nächsten + // konfigurierten Übergang aus. Die Zielrunde kann Gruppen oder K.o. sein. const ordered = normalizedStages.sort((a, b) => a.stageIndex - b.stageIndex); - const groupStage = ordered.find(s => (s.type || s.targetType || s.target) === 'groups'); - const knockoutStage = ordered.find(s => (s.type || s.targetType || s.target) === 'knockout'); + const fromStage = ordered.find(s => (s.type || s.targetType || s.target) === 'groups'); + const toStage = fromStage + ? ordered.find(s => s.stageIndex > fromStage.stageIndex) + : null; - if (groupStage && knockoutStage) { - // Falls es Zwischenstufen vom Typ 'groups' gibt, iteriere bis zur KO‑Stufe - let fromIdx = groupStage.stageIndex; - let fromId = groupStage.stageId; - for (const stage of ordered) { - if (stage.stageIndex <= fromIdx) continue; - // Advance Schrittweise zur nächsten Stage; prefer IDs if backend expects them - const payload = { - clubId: this.currentClub, - tournamentId: this.selectedDate - }; - if (Number.isFinite(fromId) && Number.isFinite(stage.stageId)) { - payload.fromStageId = fromId; - payload.toStageId = stage.stageId; - } else { - payload.fromStageIndex = fromIdx; - payload.toStageIndex = stage.stageIndex; - } - await apiClient.post('/tournament/stages/advance', payload); - - // Update trackers - fromIdx = stage.stageIndex; - fromId = stage.stageId; - - // Wenn KO erreicht, beende - if ((stage.type || stage.targetType || stage.target) === 'knockout') { - await this.loadTournamentData(); - return; - } - - // Nach jedem Schritt neu laden, damit Folgeschritt korrekte Daten hat - await this.loadTournamentData(); - } + if (fromStage && toStage) { + const payload = { + clubId: this.currentClub, + tournamentId: this.selectedDate + }; + payload.fromStageIndex = fromStage.stageIndex; + payload.toStageIndex = toStage.stageIndex; + await apiClient.post('/tournament/stages/advance', payload); + await this.loadTournamentData(); + return; } } } catch (e) { 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 7a20f203..6d59f9a6 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 @@ -621,7 +621,15 @@ internal fun TournamentEditorMatchesTab( var confirmDelete by remember { mutableStateOf?>(null) } var editingSet by remember { mutableStateOf?>(null) } var editInput by remember { mutableStateOf("") } - val hasKO = matches.any { (it.round ?: "").lowercase() != "group" && (it.round ?: "").isNotBlank() } + fun isEmptyThirdPlacePlaceholder(match: TournamentMatchDto): Boolean { + return match.round.contains("Spiel um Platz 3") && + match.player1Id == null && + match.player2Id == null && + match.player1 == null && + match.player2 == null && + match.result == null + } + val hasKO = matches.any { it.round.lowercase() != "group" && it.round.isNotBlank() && !isEmptyThirdPlacePlaceholder(it) } var showDistributedDialog by remember { mutableStateOf(false) } var distributedMatches by remember { mutableStateOf>(emptyList()) } var distributedMessage by remember { mutableStateOf(null) } @@ -681,7 +689,7 @@ internal fun TournamentEditorMatchesTab( }) { Text(tr("tournaments.distributeTables", "Freie Tische verteilen")) } } // Table-like view: show header + flattened rows so Android resembles web UI - val displayList = matches.sortedWith(compareBy { + val displayList = matches.filterNot { isEmptyThirdPlacePlaceholder(it) }.sortedWith(compareBy { it.isFinished == true }.thenBy { // primary: if groupRound available use it, else try parse round as int, else large