feat(tournament): enhance tournament configuration and results handling

- Updated TournamentConfigTab.vue to conditionally disable target type selection based on final stage type.
- Improved logic for determining target type and group count based on stage configuration.
- Refactored TournamentPlacementsTab.vue to streamline class and group placements display, including better handling of class visibility and player names.
- Enhanced TournamentResultsTab.vue to handle 'BYE' results and limit displayed entries to top three.
- Modified TournamentTab.vue to robustly determine match winners and losers, including handling 'BYE' scenarios and ensuring accurate knockout progression.
- Added logic to reset knockout matches with optional class filtering.
This commit is contained in:
Torsten Schulz (local)
2025-12-15 15:36:18 +01:00
parent 945ec0d48c
commit 047b1801b3
7 changed files with 1044 additions and 399 deletions

View File

@@ -151,12 +151,12 @@
<div style="display:flex; gap: 0.75rem; flex-wrap: wrap; align-items: end;">
<label>
Ziel:
<select v-model="rule.targetType">
<select v-model="rule.targetType" :disabled="stageConfig.finalStageType !== 'groups'">
<option value="groups">Gruppen</option>
<option value="knockout">KO</option>
</select>
</label>
<label v-if="rule.targetType === 'groups'">
<label v-if="stageConfig.finalStageType === 'groups'">
Ziel-Gruppenanzahl:
<input type="number" min="1" v-model.number="rule.targetGroupCount" />
</label>
@@ -494,7 +494,8 @@ export default {
const poolsFinal = Array.isArray(advFinal?.config?.pools) ? advFinal.config.pools : [];
this.stageConfig.poolsFinal = poolsFinal.map(p => ({
fromPlacesText: Array.isArray(p.fromPlaces) ? p.fromPlaces.join(',') : '',
targetType: p?.target?.type || this.stageConfig.finalStageType || 'knockout',
// final-stage Modus ist führend: wenn KO gewählt, setzen wir Ziel automatisch auf KO
targetType: (this.stageConfig.finalStageType === 'knockout') ? 'knockout' : (p?.target?.type || 'groups'),
targetGroupCount: p?.target?.groupCount || this.stageConfig.finalStageGroupCount || 1,
}));
@@ -511,7 +512,9 @@ export default {
const targetArray = isFinal ? this.stageConfig.poolsFinal : this.stageConfig.pools12;
targetArray.push({
fromPlacesText: '1,2',
targetType: 'groups',
targetType: isFinal
? (this.stageConfig.finalStageType === 'knockout' ? 'knockout' : 'groups')
: 'groups',
targetGroupCount: isFinal
? (this.stageConfig.finalStageGroupCount || 1)
: (this.stageConfig.stage2GroupCount || 2),
@@ -528,10 +531,12 @@ export default {
.split(',')
.map(x => Number(String(x).trim()))
.filter(n => Number.isFinite(n) && n > 0);
// Wenn Endrunde KO ist, erzwingen wir KO als Ziel, damit man KO nicht doppelt einstellen muss.
const forceKnockout = this.stageConfig.finalStageType === 'knockout';
return {
fromPlaces,
target: r.targetType === 'knockout'
? { type: 'knockout', singleField: knockoutSingleField, thirdPlace: knockoutThirdPlace }
target: (forceKnockout || r.targetType === 'knockout')
? { type: 'knockout', singleField: knockoutSingleField, thirdPlace: knockoutThirdPlace }
: { type: 'groups', groupCount: Math.max(1, Number(r.targetGroupCount || defaultGroupCount || 1)) }
};
})
@@ -638,12 +643,35 @@ export default {
this.stageConfig.error = null;
this.stageConfig.success = null;
try {
const res = await apiClient.post('/tournament/stages/advance', {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId),
fromStageIndex: Number(fromStageIndex),
toStageIndex: Number(toStageIndex),
// Lade aktuelle Stages, um passende IDs zu ermitteln
const getRes = await apiClient.get('/tournament/stages', {
params: {
clubId: Number(this.clubId),
tournamentId: Number(this.tournamentId)
}
});
const stages = Array.isArray(getRes?.data?.stages) ? getRes.data.stages : [];
const normalized = stages.map(s => ({
stageIndex: Number(s.stageIndex ?? s.index ?? s.id),
stageId: Number(s.id ?? s.stageId ?? s.stageIndex),
type: s.type || s.targetType || s.target
}));
const from = normalized.find(s => s.stageIndex === Number(fromStageIndex));
const to = normalized.find(s => s.stageIndex === Number(toStageIndex));
const payload = {
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);
}
const res = await apiClient.post('/tournament/stages/advance', payload);
if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Erstellen der Runde');
this.stageConfig.success = `Runde ${toStageIndex} wurde erstellt.`;
} catch (e) {

View File

@@ -7,28 +7,21 @@
:selected-date="selectedDate"
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
<!-- Endplatzierungen (K.O.-Runde) -->
<section v-if="Object.keys(finalPlacementsByClass).length > 0" class="final-placements">
<h3>{{ $t('tournaments.finalPlacements') }}</h3>
<template v-for="(classPlacements, classId) in finalPlacementsByClass" :key="`final-${classId}`">
<div v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))" class="class-section">
<h4 v-if="classId !== 'null' && classId !== 'undefined'" class="class-header">
{{ getClassName(classId) }}
</h4>
<h4 v-else class="class-header">
{{ $t('tournaments.withoutClass') }}
</h4>
<div v-if="shouldShowClass(classPlacements[0]?.classId ?? (classId==='null'?null:Number(classId)))" class="class-section">
<h4 class="class-header">{{ getClassName(classId) }}</h4>
<table>
<thead>
<tr>
<th>{{ $t('tournaments.position') }}</th>
<tr>
<th class="col-place">{{ labelPlace }}</th>
<th>{{ $t('tournaments.player') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, entryIdx) in classPlacements" :key="`${entry.member?.id || entryIdx}-${entryIdx}`">
<td><strong>{{ entry.position }}.</strong></td>
<tr v-for="(entry, entryIdx) in classPlacements" :key="`final-${classId}-${entryIdx}`">
<td class="col-place">{{ entry.position }}.</td>
<td>{{ getEntryPlayerName(entry) }}</td>
</tr>
</tbody>
@@ -36,34 +29,30 @@
</div>
</template>
</section>
<!-- Gruppenplatzierungen -->
<section v-if="groupPlacements.length > 0" class="group-placements">
<h3>{{ $t('tournaments.groupPlacements') }}</h3>
<template v-for="(classGroups, classId) in groupPlacementsByClass" :key="`group-${classId}`">
<div v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))" class="class-section">
<h4 v-if="classId !== 'null' && classId !== 'undefined'" class="class-header">
{{ getClassName(classId) }}
</h4>
<div v-for="group in classGroups" :key="group.groupId" class="group-table">
<h5>{{ $t('tournaments.groupNumber') }} {{ group.groupNumber }}</h5>
<div v-if="shouldShowClass(classId==='null'?null:Number(classId))" class="class-section">
<h4 class="class-header">{{ getClassName(classId) }}</h4>
<div class="group-table" v-for="(g, gi) in classGroups" :key="`group-${classId}-${gi}`">
<h5>{{ $t('tournaments.group') }} {{ g.groupNumber }}</h5>
<table>
<thead>
<tr>
<th>{{ $t('tournaments.position') }}</th>
<tr>
<th class="col-place">{{ labelPlace }}</th>
<th>{{ $t('tournaments.player') }}</th>
<th>{{ $t('tournaments.points') }}</th>
<th>{{ $t('tournaments.sets') }}</th>
<th>{{ $t('tournaments.diff') }}</th>
<th>{{ $t('tournaments.setDiff') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(pl, idx) in group.rankings" :key="pl.id">
<td><strong>{{ pl.position }}.</strong></td>
<td><span v-if="pl.seeded" class="seeded-star"></span>{{ pl.name }}</td>
<td>{{ pl.points }}</td>
<td>{{ pl.setsWon }}:{{ pl.setsLost }}</td>
<td>{{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}</td>
<tr v-for="(r, ri) in g.rankings" :key="`r-${g.groupId}-${ri}`">
<td class="col-place">{{ r.position }}.</td>
<td>{{ r.name }}</td>
<td>{{ r.points }}</td>
<td>{{ r.setsWon }}:{{ r.setsLost }}</td>
<td>{{ r.setDiff >= 0 ? '+' + r.setDiff : r.setDiff }}</td>
</tr>
</tbody>
</table>
@@ -71,7 +60,6 @@
</div>
</template>
</section>
<div v-if="Object.keys(finalPlacementsByClass).length === 0 && groupPlacements.length === 0" class="no-placements">
<p>{{ $t('tournaments.noPlacementsYet') }}</p>
</div>
@@ -83,64 +71,171 @@ import TournamentClassSelector from './TournamentClassSelector.vue';
export default {
name: 'TournamentPlacementsTab',
components: {
TournamentClassSelector
},
components: { TournamentClassSelector },
props: {
selectedDate: {
type: [String, Number],
default: null
},
selectedViewClass: {
type: [Number, String, null],
default: null
},
tournamentClasses: {
type: Array,
required: true
},
knockoutMatches: {
type: Array,
required: true
},
groups: {
type: Array,
required: true
},
groupRankings: {
type: Object,
required: true
},
groupedRankingList: {
type: Object,
required: true
},
participants: {
type: Array,
required: true
},
externalParticipants: {
type: Array,
required: true
},
pairings: {
type: Array,
required: true
}
selectedDate: { type: [String, Number], default: null },
selectedViewClass: { type: [Number, String, null], default: null },
tournamentClasses: { type: Array, required: true },
participants: { type: Array, required: true },
externalParticipants: { type: Array, required: true },
pairings: { type: Array, required: true },
groups: { type: Array, required: true },
groupRankings: { type: Object, required: true },
knockoutMatches: { type: Array, required: true }
},
emits: [
'update:selectedViewClass'
],
emits: ['update:selectedViewClass'],
computed: {
labelPlace() {
const t = this.$t && this.$t('tournaments.place');
if (t && typeof t === 'string' && t.trim().length > 0 && t !== 'tournaments.place') return t;
return 'Platz';
},
finalPlacementsByClass() {
// Verwende die bereits berechnete groupedRankingList aus TournamentTab
// Diese enthält die korrekten Platzierungen basierend auf extendedRankingList oder rankingList
return this.groupedRankingList;
const byClass = {};
const matchesByClass = {};
(this.knockoutMatches || []).forEach(m => {
const key = m.classId != null ? String(m.classId) : 'null';
(matchesByClass[key] ||= []).push(m);
});
const addEntry = (classKey, position, participant) => {
const member = participant?.member;
if (!member) return;
(byClass[classKey] ||= []);
const key = (member.id != null && Number.isFinite(Number(member.id)))
? `id:${Number(member.id)}`
: `name:${(member.firstName || '').trim()}|${(member.lastName || '').trim()}`;
const existing = byClass[classKey].find(e => {
const ek = (e.member?.id != null && Number.isFinite(Number(e.member.id)))
? `id:${Number(e.member.id)}`
: `name:${(e.member?.firstName || '').trim()}|${(e.member?.lastName || '').trim()}`;
return ek === key;
});
if (!existing) {
byClass[classKey].push({ position, member, classId: classKey === 'null' ? null : Number(classKey) });
} else if (Number(position) < Number(existing.position)) {
existing.position = position;
}
};
const parseWinnerLoser = (match) => {
if (!match || !match.isFinished) return { winner: null, loser: null };
if (String(match.result).toUpperCase() === 'BYE') {
const winner = match.player1 || match.player2 || null;
const loser = winner === match.player1 ? match.player2 : match.player1;
return { winner, loser };
}
if (typeof match.result === 'string' && match.result.includes(':')) {
const [a, b] = match.result.split(':').map(n => Number(n));
if (Number.isFinite(a) && Number.isFinite(b)) {
return { winner: a > b ? match.player1 : match.player2, loser: a > b ? match.player2 : match.player1 };
}
}
return { winner: null, loser: null };
};
Object.entries(matchesByClass).forEach(([classKey, classMatches]) => {
if (!classMatches || classMatches.length === 0) return;
const lower = (s) => (s || '').toLowerCase();
const finalMatch = classMatches.find(m => lower(m.round) === 'finale');
const thirdMatch = classMatches.find(m => lower(m.round).includes('platz 3'));
const semifinals = classMatches.filter(m => lower(m.round).includes('halbfinale'));
const quarterfinals = classMatches.filter(m => lower(m.round).includes('viertelfinale'));
const round16 = classMatches.filter(m => lower(m.round).includes('achtelfinale'));
const f = parseWinnerLoser(finalMatch);
if (f.winner) addEntry(classKey, 1, f.winner);
if (f.loser) addEntry(classKey, 2, f.loser);
const t = parseWinnerLoser(thirdMatch);
if (t.winner) addEntry(classKey, 3, t.winner);
if (t.loser) addEntry(classKey, 4, t.loser);
if (!thirdMatch || !thirdMatch.isFinished) {
semifinals.forEach(m => {
const { loser } = parseWinnerLoser(m);
if (loser) addEntry(classKey, 3, loser);
});
}
quarterfinals.forEach(m => {
const { loser } = parseWinnerLoser(m);
if (loser) addEntry(classKey, 5, loser);
});
round16.forEach(m => {
const { loser } = parseWinnerLoser(m);
if (loser) addEntry(classKey, 9, loser);
});
byClass[classKey] = (byClass[classKey] || []).sort((a, b) => Number(a.position) - Number(b.position));
});
// Ergänze alle weiteren Teilnehmer der Klasse (auch wenn sie die KO-Runde nicht erreicht haben)
// Baue Teilnehmerlisten pro Klasse aus participants und externalParticipants
const participantsByClass = {};
(this.participants || []).forEach(p => {
const key = p.classId != null ? String(p.classId) : 'null';
(participantsByClass[key] ||= []).push(p);
});
(this.externalParticipants || []).forEach(p => {
const key = p.classId != null ? String(p.classId) : 'null';
(participantsByClass[key] ||= []).push(p);
});
const getStableKeyForParticipant = (p) => {
const member = p.member || p;
if (member && member.id != null && Number.isFinite(Number(member.id))) {
return `id:${Number(member.id)}`;
}
const fn = (member?.firstName || '').trim();
const ln = (member?.lastName || '').trim();
if (!fn && !ln) return null;
return `name:${fn}|${ln}`;
};
Object.entries(participantsByClass).forEach(([classKey, plist]) => {
const existingKeys = new Set((byClass[classKey] || []).map(e => {
if (e.member?.id != null && Number.isFinite(Number(e.member.id))) return `id:${Number(e.member.id)}`;
const fn = (e.member?.firstName || '').trim();
const ln = (e.member?.lastName || '').trim();
return `name:${fn}|${ln}`;
}));
const dedupSeen = new Set();
const unique = [];
for (const p of plist) {
const k = getStableKeyForParticipant(p);
if (!k || dedupSeen.has(k)) continue;
dedupSeen.add(k);
unique.push(p);
}
const maxPos = Math.max(0, ...(byClass[classKey] || []).map(e => Number(e.position) || 0));
let nextPos = maxPos + 1;
unique.forEach(p => {
const k = getStableKeyForParticipant(p);
if (!k || existingKeys.has(k)) return;
// map participant to entry.member-like
const memberLike = p.member ? p.member : {
id: p.id,
firstName: p.firstName,
lastName: p.lastName
};
(byClass[classKey] ||= []).push({ position: nextPos++, member: memberLike, classId: classKey === 'null' ? null : Number(classKey) });
existingKeys.add(k);
});
byClass[classKey] = (byClass[classKey] || []).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;
},
groupPlacements() {
// Extrahiere Gruppenplatzierungen
const placements = [];
this.groups.forEach(group => {
const rankings = this.groupRankings[group.groupId] || [];
if (rankings.length > 0) {
@@ -161,7 +256,6 @@ export default {
});
}
});
return placements.sort((a, b) => {
if (a.classId !== b.classId) {
const aNum = a.classId || 999999;
@@ -203,131 +297,11 @@ export default {
return '';
}
},
getMatchWinner(match) {
if (!match.isFinished || !match.tournamentResults || match.tournamentResults.length === 0) {
return null;
}
const results = match.tournamentResults || [];
let win1 = 0, win2 = 0;
results.forEach(r => {
if (r.pointsPlayer1 > r.pointsPlayer2) win1++;
else if (r.pointsPlayer2 > r.pointsPlayer1) win2++;
});
if (win1 > win2) {
return this.getPlayerName(match.player1, match);
} else if (win2 > win1) {
return this.getPlayerName(match.player2, match);
}
return null;
},
getMatchLoser(match) {
if (!match.isFinished || !match.tournamentResults || match.tournamentResults.length === 0) {
return null;
}
const results = match.tournamentResults || [];
let win1 = 0, win2 = 0;
results.forEach(r => {
if (r.pointsPlayer1 > r.pointsPlayer2) win1++;
else if (r.pointsPlayer2 > r.pointsPlayer1) win2++;
});
if (win1 > win2) {
const names = this.getMatchPlayerNames(match);
return names ? names.name2 : null;
} else if (win2 > win1) {
const names = this.getMatchPlayerNames(match);
return names ? names.name1 : null;
}
return null;
},
getPlayerName(player, match) {
if (!player && !match) return this.$t('tournaments.unknown');
// Prüfe ob es ein Doppel ist
if (match && match.classId) {
const tournamentClass = this.tournamentClasses.find(c => c.id === match.classId);
if (tournamentClass && tournamentClass.isDoubles) {
// Finde die Paarung basierend auf player1Id oder player2Id
const playerId = player?.id || (match.player1Id === player?.id ? match.player1Id : match.player2Id);
const pairing = this.pairings.find(p =>
p.classId === match.classId &&
(p.member1Id === playerId || p.member2Id === playerId ||
p.external1Id === playerId || p.external2Id === playerId)
);
if (pairing) {
const name1 = this.getPairingPlayerName(pairing, 1);
const name2 = this.getPairingPlayerName(pairing, 2);
return `${name1} / ${name2}`;
}
}
}
// Normale Spieler
if (player) {
if (player.member) {
return `${player.member.firstName} ${player.member.lastName}`;
} else if (player.firstName && player.lastName) {
return `${player.firstName} ${player.lastName}`;
}
}
return this.$t('tournaments.unknown');
},
getPairingPlayerName(pairing, playerNumber) {
if (playerNumber === 1) {
if (pairing.member1 && pairing.member1.member) {
return `${pairing.member1.member.firstName} ${pairing.member1.member.lastName}`;
} else if (pairing.external1) {
return `${pairing.external1.firstName} ${pairing.external1.lastName}`;
}
} else if (playerNumber === 2) {
if (pairing.member2 && pairing.member2.member) {
return `${pairing.member2.member.firstName} ${pairing.member2.member.lastName}`;
} else if (pairing.external2) {
return `${pairing.external2.firstName} ${pairing.external2.lastName}`;
}
}
return this.$t('tournaments.unknown');
},
getMatchPlayerNames(match) {
const classId = match.classId;
if (classId) {
const tournamentClass = this.tournamentClasses.find(c => c.id === classId);
if (tournamentClass && tournamentClass.isDoubles) {
const pairing1 = this.pairings.find(p =>
p.classId === classId &&
(p.member1Id === match.player1Id || p.external1Id === match.player1Id ||
p.member2Id === match.player1Id || p.external2Id === match.player1Id)
);
const pairing2 = this.pairings.find(p =>
p.classId === classId &&
(p.member1Id === match.player2Id || p.external1Id === match.player2Id ||
p.member2Id === match.player2Id || p.external2Id === match.player2Id)
);
if (pairing1 && pairing2) {
const name1 = this.getPairingPlayerName(pairing1, 1) + ' / ' + this.getPairingPlayerName(pairing1, 2);
const name2 = this.getPairingPlayerName(pairing2, 1) + ' / ' + this.getPairingPlayerName(pairing2, 2);
return { name1, name2 };
}
}
}
return {
name1: this.getPlayerName(match.player1, match),
name2: this.getPlayerName(match.player2, match)
};
},
getEntryPlayerName(entry) {
// Die entry hat die Struktur: { position, member, classId }
// member ist ein Member-Objekt mit firstName/lastName direkt
if (entry.member) {
if (entry.member.firstName && entry.member.lastName) {
return `${entry.member.firstName} ${entry.member.lastName}`;
}
}
const m = entry.member || {};
const fn = (m.firstName || '').trim();
const ln = (m.lastName || '').trim();
if (fn || ln) return `${fn} ${ln}`.trim();
return this.$t('tournaments.unknown');
}
}
@@ -380,6 +354,11 @@ th {
font-weight: 600;
}
/* Platz-Spalte kompakt */
.col-place {
width: 4em;
}
.seeded-star {
color: #ff9800;
margin-right: 0.25rem;
@@ -391,4 +370,10 @@ th {
color: #666;
}
</style>
/* Spaltenbreite für Platz: 4em */
table thead th:first-child,
table tbody td:first-child {
width: 4em;
}

View File

@@ -45,7 +45,10 @@
</template>
</td>
<td>
<template v-if="!m.isFinished">
<template v-if="m.result === 'BYE'">
BYE
</template>
<template v-else-if="!m.isFinished">
<template v-for="r in m.tournamentResults" :key="r.set">
<template v-if="isEditing(m, r.set)">
<input
@@ -202,7 +205,7 @@
</tr>
</thead>
<tbody>
<tr v-for="(entry, entryIdx) in groupedRankingList[classKey]" :key="`${entry.member.id}-${entryIdx}`">
<tr v-for="(entry, entryIdx) in groupedRankingList[classKey].slice(0, 3)" :key="`${entry.member.id}-${entryIdx}`">
<td>{{ entry.position }}.</td>
<td>
{{ entry.member.firstName }}
@@ -356,7 +359,7 @@ export default {
};
},
getPlayerName(p) {
if (!p) return 'TBD';
if (!p) return 'Freilos';
if (p.member) {
return p.member.firstName + ' ' + p.member.lastName;
} else {
@@ -396,8 +399,12 @@ export default {
return `${win1}:${win2}`;
},
winnerIsPlayer1(match) {
const [w1, w2] = this.getSetsString(match).split(':').map(Number);
return w1 > w2;
if (match.result === 'BYE') {
// Gewinner ist der vorhandene Spieler
return !!match.player1 && !match.player2;
}
const [t, n] = this.getSetsString(match).split(':').map(Number);
return t > n;
},
isEditing(match, set) {
return (

View File

@@ -636,8 +636,17 @@ export default {
// Finde alle Spieler, die noch im Turnier sind (Gewinner von abgeschlossenen Matches, die noch nicht ausgeschieden sind)
const stillInTournament = new Set();
finishedMatches.forEach(match => {
const [a, b] = match.result.split(':').map(n => +n);
const winner = a > b ? match.player1 : match.player2;
// BYE oder regulär robust auswerten
let winner = null;
if (match.result === 'BYE') {
winner = match.player1 || match.player2 || null;
} else if (typeof match.result === 'string' && match.result.includes(':')) {
const [sa, sb] = match.result.split(':').map(n => Number(n));
if (Number.isFinite(sa) && Number.isFinite(sb)) {
winner = sa > sb ? match.player1 : match.player2;
}
}
if (!winner) return;
const winnerId = winner.member ? winner.member.id : winner.id;
// Prüfe, ob der Gewinner noch in einem nicht abgeschlossenen Match ist
@@ -645,9 +654,9 @@ export default {
!m.isFinished &&
m.classId === match.classId && // WICHTIG: Nur Matches derselben Klasse prüfen
((m.player1 && m.player1.member && m.player1.member.id === winnerId) ||
(m.player1 && m.player1.id === winnerId) ||
(m.player2 && m.player2.member && m.player2.member.id === winnerId) ||
(m.player2 && m.player2.id === winnerId))
(m.player1 && m.player1.id === winnerId) ||
(m.player2 && m.player2.member && m.player2.member.id === winnerId) ||
(m.player2 && m.player2.id === winnerId))
);
if (hasUnfinishedMatch) {
@@ -667,6 +676,7 @@ export default {
// Verarbeite jede Klasse separat
Object.entries(matchesByClass).forEach(([classKey, classMatches]) => {
const hasThirdPlace = classMatches.some(m => (m.round || '').toLowerCase().includes('platz 3'));
// Gruppiere nach Runden innerhalb dieser Klasse
const roundsMap = {};
classMatches.forEach(m => {
@@ -686,16 +696,36 @@ export default {
return b[1].length - a[1].length;
});
// Bestimme Positionen basierend auf abgeschlossenen Runden (pro Klasse)
let currentPosition = 1;
// Hilfsfunktion: Positionszahl für KO-Runden nach Anzahl Matches
const positionForRound = (roundName, matchesCount) => {
const rn = (roundName || '').toLowerCase();
if (rn === 'finale') return 1; // wird separat behandelt
if (rn.includes('halbfinale')) return 3; // nur wenn KEIN Platz-3-Spiel existiert
// Viertelfinale: 4 Matches -> Plätze 5..8
if (rn.includes('viertelfinale')) return 5;
// Achtelfinale: 8 Matches -> Plätze 9..16
if (rn.includes('achtelfinale')) return 9;
// Generisch: matchesCount + 1
return Number(matchesCount) + 1;
};
sortedRounds.forEach(([roundName, matches]) => {
if (roundName === 'finale') {
// Finale: 1. und 2. Platz
const match = matches[0];
const [s1, s2] = match.result.split(':').map(n => +n);
const winner = s1 > s2 ? match.player1 : match.player2;
const loser = s1 > s2 ? match.player2 : match.player1;
let winner = null;
let loser = null;
if (match.result === 'BYE') {
winner = match.player1 || match.player2 || null;
loser = winner === match.player1 ? match.player2 : match.player1;
} else if (typeof match.result === 'string' && match.result.includes(':')) {
const [s1, s2] = match.result.split(':').map(n => Number(n));
if (Number.isFinite(s1) && Number.isFinite(s2)) {
winner = s1 > s2 ? match.player1 : match.player2;
loser = s1 > s2 ? match.player2 : match.player1;
}
}
if (!winner || !loser) return;
const winnerId = winner.member ? winner.member.id : winner.id;
const loserId = loser.member ? loser.member.id : loser.id;
@@ -706,24 +736,85 @@ export default {
if (!stillInTournament.has(`${classKey}-${loserId}`)) {
list.push({ position: 2, member: loser.member, classId: match.classId });
}
currentPosition = 3;
// Finale setzt 1/2, keine globale Positionsvariable nötig
} else {
// Überspringe Platz-3-Runde hier; sie wird separat als 3/4 bewertet
if ((roundName || '').toLowerCase().includes('platz 3')) {
return;
}
// Andere Runden: Alle Verlierer bekommen die gleiche Position
const numMatches = matches.length;
const position = currentPosition;
let position = positionForRound(roundName, numMatches);
matches.forEach(match => {
const [a, b] = match.result.split(':').map(n => +n);
const knockedOut = a > b ? match.player2 : match.player1;
let knockedOut = null;
if (match.result === 'BYE') {
// Der fehlende Spieler gilt als ausgeschieden
knockedOut = (!match.player1) ? match.player1 : (!match.player2 ? match.player2 : null);
// Falls beide vorhanden (sollte nicht BYE sein), fall back
if (!knockedOut) {
const [a, b] = (match.result || '').split(':').map(n => Number(n));
if (Number.isFinite(a) && Number.isFinite(b)) {
knockedOut = a > b ? match.player2 : match.player1;
}
}
} else {
const [a, b] = (match.result || '').split(':').map(n => Number(n));
if (Number.isFinite(a) && Number.isFinite(b)) {
knockedOut = a > b ? match.player2 : match.player1;
}
}
if (!knockedOut) return;
const knockedOutId = knockedOut.member ? knockedOut.member.id : knockedOut.id;
// Wenn ein Platz-3-Spiel existiert, füge Halbfinal-Verlierer hier NICHT als Platz 3 hinzu
const isSemi = (match.round || '').toLowerCase().includes('halbfinale');
if (hasThirdPlace && isSemi) return;
// Nur hinzufügen, wenn nicht mehr im Turnier
if (!stillInTournament.has(`${classKey}-${knockedOutId}`)) {
list.push({ position: position, member: knockedOut.member, classId: match.classId });
}
});
currentPosition += numMatches;
// Bei Viertelfinale/Achtelfinale erhalten Verlierer die korrekten Startpositionen (5 bzw. 9)
}
});
// Platz-3-Spiel explizit werten (3/4)
const thirdMatch = classMatches.find(m => (m.round || '').toLowerCase().includes('platz 3'));
if (thirdMatch && thirdMatch.isFinished) {
let winner = null;
let loser = null;
if (thirdMatch.result === 'BYE') {
winner = thirdMatch.player1 || thirdMatch.player2 || null;
loser = winner === thirdMatch.player1 ? thirdMatch.player2 : thirdMatch.player1;
} else if (typeof thirdMatch.result === 'string' && thirdMatch.result.includes(':')) {
const [s1, s2] = thirdMatch.result.split(':').map(n => Number(n));
if (Number.isFinite(s1) && Number.isFinite(s2)) {
winner = s1 > s2 ? thirdMatch.player1 : thirdMatch.player2;
loser = s1 > s2 ? thirdMatch.player2 : thirdMatch.player1;
}
}
if (winner && winner.member) list.push({ position: 3, member: winner.member, classId: thirdMatch.classId });
if (loser && loser.member) list.push({ position: 4, member: loser.member, classId: thirdMatch.classId });
}
});
// Dedupliziere Einträge pro Klasse/Spieler und behalte die beste (niedrigste) Position
const dedup = new Map();
const out = [];
for (const entry of list) {
const key = `${entry.classId || 'null'}:${entry.member?.id ?? (entry.member?.firstName || '') + '|' + (entry.member?.lastName || '')}`;
const existing = dedup.get(key);
if (!existing || Number(entry.position) < Number(existing.position)) {
dedup.set(key, entry);
}
}
for (const v of dedup.values()) out.push(v);
return out.sort((a, b) => {
const ac = a.classId || 999999;
const bc = b.classId || 999999;
if (ac !== bc) return ac - bc;
return Number(a.position) - Number(b.position);
});
return list.sort((a, b) => {
@@ -770,9 +861,8 @@ export default {
},
canResetKnockout() {
// KOMatches existieren und keiner ist beendet
return this.knockoutMatches.length > 0
&& this.knockoutMatches.every(m => !m.isFinished);
// Zeige den LöschenButton, sobald KOMatches existieren
return this.knockoutMatches.length > 0;
},
},
watch: {
@@ -1650,7 +1740,76 @@ export default {
}
},
async startKnockout() {
async startKnockout() {
// Wenn eine Stage-Konfiguration existiert, ist /tournament/stages/advance der
// korrekte Weg, weil nur dort die Pool-Regeln (z.B. Plätze 1,2) berücksichtigt werden.
// Fallback ist die Legacy-Route /tournament/knockout.
try {
const stagesRes = await apiClient.get('/tournament/stages', {
params: {
clubId: this.currentClub,
tournamentId: this.selectedDate
}
});
const stages = stagesRes?.data?.stages;
if (Array.isArray(stages) && stages.length > 0) {
// Backend arbeitet mit expliziten Stage-Indizes (z.B. 1 und 3), die nicht
// zwingend 1..N sind. Daher müssen wir die Indizes aus der Antwort ableiten.
const normalizedStages = stages
.map(s => ({
...s,
// Prefer explicit index field; fall back to id for ordering if needed
stageIndex: Number(s.stageIndex ?? s.index ?? s.id),
stageId: Number(s.id ?? s.stageId ?? s.stageIndex)
}))
.filter(s => Number.isFinite(s.stageIndex));
// Ermittle die Reihenfolge der Stages
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');
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();
}
}
}
} catch (e) {
// Ignorieren und Legacy-Fallback nutzen.
// (z.B. wenn Endpoint nicht verfügbar oder Stages nicht konfiguriert)
}
await apiClient.post('/tournament/knockout', {
clubId: this.currentClub,
tournamentId: this.selectedDate
@@ -1862,12 +2021,14 @@ export default {
async resetKnockout() {
try {
await apiClient.delete('/tournament/matches/knockout', {
data: {
clubId: this.currentClub,
tournamentId: this.selectedDate
}
});
const payload = {
clubId: this.currentClub,
tournamentId: this.selectedDate
};
if (this.selectedViewClass != null && this.selectedViewClass !== '__none__') {
payload.classId = Number(this.selectedViewClass);
}
await apiClient.delete('/tournament/matches/knockout', { data: payload });
await this.loadTournamentData();
} catch (err) {
const message = safeErrorMessage(err, this.$t('tournaments.errorResettingKORound'));