Fixed tournament - groups in end round and place 3
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s

This commit is contained in:
Torsten Schulz (local)
2026-06-10 08:08:31 +02:00
parent 5423f24969
commit 8d1bce2ff9
21 changed files with 396 additions and 153 deletions

View File

@@ -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');

View File

@@ -68,7 +68,7 @@
</h4>
</div>
<div v-for="group in classGroups" :key="group.groupId" class="group-table">
<h4>{{ $t('tournaments.groupNumber') }} {{ group.groupNumber }}</h4>
<h4>{{ group.groupLabel || ($t('tournaments.groupNumber') + ' ' + group.groupNumber) }}</h4>
<table>
<thead>
<tr>

View File

@@ -53,7 +53,7 @@
</span>
</h4>
<div class="group-table" v-for="(g, gi) in classGroups" :key="`group-${classId}-${gi}`">
<h5>{{ $t('tournaments.group') }} {{ g.groupNumber }}</h5>
<h5>{{ g.groupLabel || ($t('tournaments.group') + ' ' + g.groupNumber) }}</h5>
<table>
<thead>
<tr>
@@ -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,

View File

@@ -7,9 +7,9 @@
:selected-date="selectedDate"
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
<section v-if="filteredGroupMatches.length" class="group-matches">
<section v-for="section in groupMatchSections" :key="section.key" class="group-matches">
<div style="display:flex; align-items:center; justify-content:space-between;">
<h4>{{ $t('tournaments.groupMatches') }}</h4>
<h4>{{ section.title }}</h4>
<div>
<button v-if="numberOfTables" @click="onDistributeTables" class="btn-secondary">{{ $t('tournaments.distributeTables') }}</button>
</div>
@@ -27,14 +27,14 @@
</tr>
</thead>
<tbody>
<tr v-for="m in filteredGroupMatches" :key="m.id" :data-match-id="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
<tr v-for="m in section.matches" :key="m.id" :data-match-id="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
<td>{{ m.groupRound }}</td>
<td>
<template v-if="getGroupClassName(m.groupId)">
{{ getGroupClassName(m.groupId) }} - {{ $t('tournaments.groupNumber') }} {{ m.groupNumber }}
{{ getGroupClassName(m.groupId) }} - {{ m.groupLabel || ($t('tournaments.groupNumber') + ' ' + m.groupNumber) }}
</template>
<template v-else>
{{ $t('tournaments.groupNumber') }} {{ m.groupNumber }}
{{ m.groupLabel || ($t('tournaments.groupNumber') + ' ' + m.groupNumber) }}
</template>
</td>
<td>
@@ -125,12 +125,12 @@
</div>
<div v-if="canStartKnockout && !showKnockout && numberOfGroupsForSelectedClass > 1" class="ko-start">
<button @click="$emit('start-knockout')">
{{ $t('tournaments.startKORound') }}
{{ $t('tournaments.startFinalRound') }}
</button>
</div>
<div v-if="showKnockout && canResetKnockout && numberOfGroupsForSelectedClass > 1" class="ko-reset" style="margin-top:1rem">
<button @click="$emit('reset-knockout')" class="trash-btn">
🗑 {{ $t('tournaments.deleteKORound') }}
🗑 {{ $t('tournaments.deleteFinalRound') }}
</button>
</div>
<section v-if="showKnockout && numberOfGroupsForSelectedClass > 1 && filteredKnockoutMatches.length" class="ko-round">
@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1028,7 +1028,9 @@
"setDiff": "文の違い",
"createMatches": "マッチを設定する",
"startKORound": "ノックアウトラウンドを開始する",
"startFinalRound": "決勝ラウンドを開始",
"deleteKORound": "ノックアウト",
"deleteFinalRound": "決勝ラウンドを削除",
"email": "電子メールアドレス",
"forForwarding": "転送用",
"showPlayerDetails": "プレーヤーの詳細を表示する",

View File

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

View File

@@ -1027,7 +1027,9 @@
"setDiff": "ความแตกต่างของประโยค",
"createMatches": "ตั้งค่าการแข่งขัน",
"startKORound": "เริ่มรอบน็อคเอาท์",
"startFinalRound": "เริ่มรอบสุดท้าย",
"deleteKORound": "น็อคเอาท์",
"deleteFinalRound": "ลบรอบสุดท้าย",
"email": "ที่อยู่อีเมล",
"forForwarding": "สำหรับการส่งต่อ",
"showPlayerDetails": "ดูรายละเอียดผู้เล่น",

View File

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

View File

@@ -1028,7 +1028,9 @@
"setDiff": "句子差异",
"createMatches": "设置匹配项",
"startKORound": "开始淘汰赛",
"startFinalRound": "开始决赛轮",
"deleteKORound": "昏死",
"deleteFinalRound": "删除决赛轮",
"email": "电子邮件",
"forForwarding": "用于转发",
"showPlayerDetails": "查看玩家详细信息",

View File

@@ -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öschenButton, sobald KOMatches 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 KOStufe
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) {