feat(TournamentService, TournamentResultsTab): enhance knockout match handling and UI interactions
- Introduced new functions for determining knockout round order and building preferred knockout matches based on qualifiers. - Updated TournamentResultsTab to include collapsible sections for group matches and knockout rounds, improving user experience. - Added data properties and methods to manage the visibility of match sections and handle tournament class checks. - Refined UI elements for better interaction, including toggle buttons and improved styling for match sections.
This commit is contained in:
@@ -203,6 +203,16 @@ function getLoserId(match) {
|
||||
return (w1 > w2) ? match.player2Id : match.player1Id;
|
||||
}
|
||||
|
||||
function getKnockoutRoundOrder(roundName) {
|
||||
if (!roundName || typeof roundName !== 'string') return null;
|
||||
if (roundName === THIRD_PLACE_ROUND) return 98;
|
||||
if (roundName.includes('Achtelfinale')) return 10;
|
||||
if (roundName.includes('Viertelfinale')) return 20;
|
||||
if (roundName.includes('Halbfinale')) return 30;
|
||||
if (roundName.includes('Finale')) return 40;
|
||||
return null;
|
||||
}
|
||||
|
||||
function shuffleInPlace(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
@@ -249,6 +259,49 @@ function compareAdvancementCandidates(a, b) {
|
||||
return Number(a.id || 0) - Number(b.id || 0);
|
||||
}
|
||||
|
||||
function compareQualifierGroups(a, b) {
|
||||
const aNum = Number(a);
|
||||
const bNum = Number(b);
|
||||
const aFinite = Number.isFinite(aNum);
|
||||
const bFinite = Number.isFinite(bNum);
|
||||
if (aFinite && bFinite && aNum !== bNum) return aNum - bNum;
|
||||
return String(a).localeCompare(String(b), 'de');
|
||||
}
|
||||
|
||||
function buildPreferredLegacyKnockoutMatches(qualifiersByGroup, advancingPerGroup) {
|
||||
if (Number(advancingPerGroup) !== 2) return null;
|
||||
|
||||
const groups = Object.keys(qualifiersByGroup).sort(compareQualifierGroups);
|
||||
if (groups.length < 2) return null;
|
||||
|
||||
const winners = [];
|
||||
const runners = [];
|
||||
for (const groupKey of groups) {
|
||||
const ranked = Array.isArray(qualifiersByGroup[groupKey]) ? qualifiersByGroup[groupKey] : [];
|
||||
const winner = ranked.find(q => Number(q.position) === 1) || ranked[0];
|
||||
const runnerUp = ranked.find(q => Number(q.position) === 2) || ranked[1];
|
||||
if (!winner || !runnerUp) return null;
|
||||
winners.push(winner);
|
||||
runners.push(runnerUp);
|
||||
}
|
||||
|
||||
const runnerOrder = groups.length % 2 === 0
|
||||
? [...runners].reverse()
|
||||
: [...runners.slice(1), runners[0]];
|
||||
|
||||
const matches = [];
|
||||
for (let i = 0; i < winners.length; i++) {
|
||||
const player1 = winners[i];
|
||||
const player2 = runnerOrder[i];
|
||||
if (!player1 || !player2 || !player1.id || !player2.id) continue;
|
||||
if (player1.id === player2.id) return null;
|
||||
if (String(player1.groupId) === String(player2.groupId)) return null;
|
||||
matches.push({ player1, player2 });
|
||||
}
|
||||
|
||||
return matches.length > 0 ? matches : null;
|
||||
}
|
||||
|
||||
const THIRD_PLACE_ROUND = 'Spiel um Platz 3';
|
||||
class TournamentService {
|
||||
/**
|
||||
@@ -3011,77 +3064,74 @@ Ve // 2. Neues Turnier anlegen
|
||||
qualifiersByGroup[groupKey].sort((a, b) => a.position - b.position);
|
||||
});
|
||||
|
||||
// Erstelle eine flache Liste aller Qualifiers, gruppiert nach Gruppen
|
||||
const groups = Object.keys(qualifiersByGroup);
|
||||
const advancingPerGroup = t.advancingPerGroup;
|
||||
|
||||
// Erstelle Paarungen: 1. gegen letzter weitergekommener, 2. gegen vorletzter, etc.
|
||||
// Wichtig: Niemand darf gegen jemanden aus der eigenen Gruppe spielen
|
||||
const matches = [];
|
||||
const usedQualifiers = new Set();
|
||||
|
||||
// Für jede Position (1., 2., 3., etc.)
|
||||
for (let pos = 1; pos <= advancingPerGroup; pos++) {
|
||||
// Finde alle Qualifiers mit dieser Position
|
||||
const qualifiersAtPosition = [];
|
||||
groups.forEach(groupKey => {
|
||||
const groupQualifiers = qualifiersByGroup[groupKey];
|
||||
const qualifierAtPos = groupQualifiers.find(q => q.position === pos);
|
||||
if (qualifierAtPos && !usedQualifiers.has(qualifierAtPos.id)) {
|
||||
qualifiersAtPosition.push(qualifierAtPos);
|
||||
}
|
||||
});
|
||||
|
||||
// Paare jeden Qualifier dieser Position mit dem entsprechenden Gegner
|
||||
// 1. Platz spielt gegen letzter weitergekommener Platz (z.B. bei 2 weiterkommenden: 1. gegen 2.)
|
||||
// 2. Platz spielt gegen vorletzter weitergekommener Platz (z.B. bei 2 weiterkommenden: 2. gegen 1.)
|
||||
const opponentPosition = advancingPerGroup - pos + 1;
|
||||
|
||||
qualifiersAtPosition.forEach(qualifier => {
|
||||
// Finde Gegner mit opponentPosition aus einer anderen Gruppe
|
||||
let opponent = null;
|
||||
for (const groupKey of groups) {
|
||||
if (groupKey === qualifier.groupId.toString()) continue; // Nicht aus derselben Gruppe
|
||||
|
||||
let matches = buildPreferredLegacyKnockoutMatches(qualifiersByGroup, advancingPerGroup);
|
||||
|
||||
if (!matches) {
|
||||
const groups = Object.keys(qualifiersByGroup).sort(compareQualifierGroups);
|
||||
|
||||
// Fallback für Sonderfälle jenseits des Standardfalls "Top 2 aus jeder Gruppe".
|
||||
// Wichtig: Niemand darf gegen jemanden aus der eigenen Gruppe spielen.
|
||||
matches = [];
|
||||
const usedQualifiers = new Set();
|
||||
|
||||
for (let pos = 1; pos <= advancingPerGroup; pos++) {
|
||||
const qualifiersAtPosition = [];
|
||||
groups.forEach(groupKey => {
|
||||
const groupQualifiers = qualifiersByGroup[groupKey];
|
||||
const opponentCandidate = groupQualifiers.find(q =>
|
||||
q.position === opponentPosition && !usedQualifiers.has(q.id)
|
||||
);
|
||||
|
||||
if (opponentCandidate) {
|
||||
opponent = opponentCandidate;
|
||||
break;
|
||||
const qualifierAtPos = groupQualifiers.find(q => q.position === pos);
|
||||
if (qualifierAtPos && !usedQualifiers.has(qualifierAtPos.id)) {
|
||||
qualifiersAtPosition.push(qualifierAtPos);
|
||||
}
|
||||
}
|
||||
|
||||
// Falls kein Gegner gefunden, suche nach einem beliebigen Gegner aus einer anderen Gruppe
|
||||
if (!opponent) {
|
||||
});
|
||||
|
||||
const opponentPosition = advancingPerGroup - pos + 1;
|
||||
|
||||
qualifiersAtPosition.forEach(qualifier => {
|
||||
let opponent = null;
|
||||
for (const groupKey of groups) {
|
||||
if (groupKey === qualifier.groupId.toString()) continue;
|
||||
|
||||
|
||||
const groupQualifiers = qualifiersByGroup[groupKey];
|
||||
const opponentCandidate = groupQualifiers.find(q => !usedQualifiers.has(q.id));
|
||||
|
||||
const opponentCandidate = groupQualifiers.find(q =>
|
||||
q.position === opponentPosition && !usedQualifiers.has(q.id)
|
||||
);
|
||||
|
||||
if (opponentCandidate) {
|
||||
opponent = opponentCandidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opponent) {
|
||||
matches.push({ player1: qualifier, player2: opponent });
|
||||
usedQualifiers.add(qualifier.id);
|
||||
usedQualifiers.add(opponent.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (!opponent) {
|
||||
for (const groupKey of groups) {
|
||||
if (groupKey === qualifier.groupId.toString()) continue;
|
||||
|
||||
const groupQualifiers = qualifiersByGroup[groupKey];
|
||||
const opponentCandidate = groupQualifiers.find(q => !usedQualifiers.has(q.id));
|
||||
|
||||
if (opponentCandidate) {
|
||||
opponent = opponentCandidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opponent) {
|
||||
matches.push({ player1: qualifier, player2: opponent });
|
||||
usedQualifiers.add(qualifier.id);
|
||||
usedQualifiers.add(opponent.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Falls Qualifiers übrig bleiben (ungerade Teilnehmerzahl / keine gültige Paarung möglich):
|
||||
// Freilos vergeben. Wir erzeugen KEIN Match mit doppelten Spielern.
|
||||
// Der Spieler mit Freilos wird in späteren Runden berücksichtigt, sobald dort (durch Ergebnisse)
|
||||
// echte Gegner feststehen. (Passt zur Vorgabe: keine Placeholder-Matches ohne bekannte Spieler.)
|
||||
const unusedQualifiers = classQualifiers.filter(q => q && q.id && !usedQualifiers.has(q.id));
|
||||
const usedQualifierIds = new Set(matches.flatMap(match => [match.player1?.id, match.player2?.id]).filter(id => Number.isFinite(id) && id > 0));
|
||||
const unusedQualifiers = classQualifiers.filter(q => q && q.id && !usedQualifierIds.has(q.id));
|
||||
if (unusedQualifiers.length > 0) {
|
||||
devLog(`[startKnockout] Assigning ${unusedQualifiers.length} bye(s) for class ${classKey}:`, unusedQualifiers.map(q => q.id));
|
||||
}
|
||||
@@ -3551,8 +3601,43 @@ Ve // 2. Neues Turnier anlegen
|
||||
throw new Error('Spiele mit aufgegebenen Spielern können nicht wieder geöffnet werden.');
|
||||
}
|
||||
|
||||
const currentRoundOrder = getKnockoutRoundOrder(match.round);
|
||||
if (currentRoundOrder != null) {
|
||||
const dependentWhere = {
|
||||
tournamentId,
|
||||
classId: match.classId ?? null,
|
||||
id: { [Op.ne]: match.id }
|
||||
};
|
||||
|
||||
if (match.stageId) {
|
||||
dependentWhere.stageId = match.stageId;
|
||||
} else {
|
||||
dependentWhere.stageId = null;
|
||||
}
|
||||
|
||||
if (match.groupId) {
|
||||
dependentWhere.groupId = match.groupId;
|
||||
} else {
|
||||
dependentWhere.groupId = null;
|
||||
}
|
||||
|
||||
const dependentMatches = await TournamentMatch.findAll({ where: dependentWhere });
|
||||
const matchesToDelete = dependentMatches.filter(candidate => {
|
||||
const candidateOrder = getKnockoutRoundOrder(candidate.round);
|
||||
return candidateOrder != null && candidateOrder > currentRoundOrder;
|
||||
});
|
||||
|
||||
if (matchesToDelete.length > 0) {
|
||||
const dependentIds = matchesToDelete.map(candidate => candidate.id);
|
||||
await TournamentResult.destroy({ where: { matchId: { [Op.in]: dependentIds } } });
|
||||
await TournamentMatch.destroy({ where: { id: { [Op.in]: dependentIds } } });
|
||||
}
|
||||
}
|
||||
|
||||
// Nur den Abschluss‑Status zurücksetzen, nicht die Einzelsätze
|
||||
match.isFinished = false;
|
||||
match.isActive = false;
|
||||
match.tableNumber = null;
|
||||
match.result = null; // optional: entfernt die zusammengefasste Ergebnis‑Spalte
|
||||
await match.save();
|
||||
}
|
||||
|
||||
@@ -42,10 +42,16 @@
|
||||
</div>
|
||||
<section v-if="filteredGroupMatches.length" class="group-matches">
|
||||
<div class="results-section-header">
|
||||
<h4>{{ $t('tournaments.groupMatches') }}</h4>
|
||||
<span class="results-chip">{{ filteredGroupMatches.length }}</span>
|
||||
<div class="results-section-title">
|
||||
<h4>{{ $t('tournaments.groupMatches') }}</h4>
|
||||
<span class="results-chip">{{ filteredGroupMatches.length }}</span>
|
||||
</div>
|
||||
<button type="button" class="section-toggle-btn" @click="groupMatchesCollapsed = !groupMatchesCollapsed">
|
||||
<span class="collapse-icon" :class="{ expanded: !groupMatchesCollapsed }">▾</span>
|
||||
{{ groupMatchesCollapsed ? collapseShowLabel : collapseHideLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<table>
|
||||
<table v-if="!groupMatchesCollapsed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tournaments.round') }}</th>
|
||||
@@ -177,8 +183,18 @@
|
||||
</div>
|
||||
<section v-if="showKnockout && numberOfGroupsForSelectedClass > 1 && filteredKnockoutMatches.length" class="ko-round">
|
||||
<div class="results-section-header">
|
||||
<h4>{{ $t('tournaments.koRound') }}</h4>
|
||||
<span class="results-chip">{{ filteredKnockoutMatches.length }}</span>
|
||||
<div class="results-section-title">
|
||||
<h4>{{ $t('tournaments.koRound') }}</h4>
|
||||
<span class="results-chip">{{ filteredKnockoutMatches.length }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="numberOfTables"
|
||||
type="button"
|
||||
class="btn-primary btn-inline-action"
|
||||
@click="$emit('distribute-tables')"
|
||||
>
|
||||
{{ $t('tournaments.distributeTables') }}
|
||||
</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -487,6 +503,25 @@ export default {
|
||||
'start-knockout',
|
||||
'reset-knockout'
|
||||
],
|
||||
data() {
|
||||
const show = this.$t('common.show');
|
||||
const hide = this.$t('common.hide');
|
||||
return {
|
||||
groupMatchesCollapsed: false,
|
||||
collapseShowLabel: show && show !== 'common.show' ? show : 'Anzeigen',
|
||||
collapseHideLabel: hide && hide !== 'common.hide' ? hide : 'Ausblenden'
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
showKnockout: {
|
||||
immediate: true,
|
||||
handler(newValue) {
|
||||
if (newValue && this.filteredGroupMatches.length > 0) {
|
||||
this.groupMatchesCollapsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterMatchesByClass(matches) {
|
||||
// Wenn keine Klasse ausgewählt ist (null), zeige alle
|
||||
@@ -707,10 +742,45 @@ export default {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.results-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.results-section-header h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.section-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-color, #ffffff);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
display: inline-block;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.collapse-icon.expanded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.btn-inline-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Farbmarkierungen für Spiele */
|
||||
.match-finished {
|
||||
background-color: var(--background-soft) !important;
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
"conflictSuggestionLabel": "Vorschläg:",
|
||||
"workspaceProblemsTitle": "{count} offeni Pünkt",
|
||||
"problemConfigTitle": "Konfiguration unvollständig",
|
||||
"problemConfigDescription": "Prüef Datum, Name, Gwünnsätz und mindestens e Klass.",
|
||||
"problemConfigDescription": "Prüef Datum, Name und Gwünnsätz.",
|
||||
"problemUnassignedTitle": "{count} Teilnehmendi ohni Klass",
|
||||
"problemUnassignedDescription": "Die Teilnehmendi bruuched no e manuelli Klassenzuewisig.",
|
||||
"problemUnassignedAutoDescription": "{count} devo chönd direkt automatisch zuegordnet werde.",
|
||||
|
||||
@@ -967,7 +967,7 @@
|
||||
"conflictSuggestionLabel": "Vorschläge:",
|
||||
"workspaceProblemsTitle": "{count} offene Punkte",
|
||||
"problemConfigTitle": "Konfiguration unvollständig",
|
||||
"problemConfigDescription": "Prüfe Datum, Name, Gewinnsätze und mindestens eine Klasse.",
|
||||
"problemConfigDescription": "Prüfe Datum, Name und Gewinnsätze.",
|
||||
"problemUnassignedTitle": "{count} Teilnehmer ohne Klasse",
|
||||
"problemUnassignedDescription": "Diese Teilnehmer brauchen noch eine manuelle Klassenzuordnung.",
|
||||
"problemUnassignedAutoDescription": "{count} davon können direkt automatisch zugeordnet werden.",
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
"conflictSuggestionLabel": "Suggestions:",
|
||||
"workspaceProblemsTitle": "{count} open issues",
|
||||
"problemConfigTitle": "Configuration incomplete",
|
||||
"problemConfigDescription": "Check date, name, winning sets and at least one class.",
|
||||
"problemConfigDescription": "Check date, name and winning sets.",
|
||||
"problemUnassignedTitle": "{count} participants without class",
|
||||
"problemUnassignedDescription": "These participants still need a manual class assignment.",
|
||||
"problemUnassignedAutoDescription": "{count} of them can be assigned automatically right away.",
|
||||
|
||||
@@ -529,7 +529,7 @@
|
||||
"conflictSuggestionLabel": "Suggestions:",
|
||||
"workspaceProblemsTitle": "{count} open issues",
|
||||
"problemConfigTitle": "Configuration incomplete",
|
||||
"problemConfigDescription": "Check date, name, winning sets and at least one class.",
|
||||
"problemConfigDescription": "Check date, name and winning sets.",
|
||||
"problemUnassignedTitle": "{count} participants without class",
|
||||
"problemUnassignedDescription": "These participants still need a manual class assignment.",
|
||||
"problemUnassignedAutoDescription": "{count} of them can be assigned automatically right away.",
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
"conflictSuggestionLabel": "Suggestions:",
|
||||
"workspaceProblemsTitle": "{count} open issues",
|
||||
"problemConfigTitle": "Configuration incomplete",
|
||||
"problemConfigDescription": "Check date, name, winning sets and at least one class.",
|
||||
"problemConfigDescription": "Check date, name and winning sets.",
|
||||
"problemUnassignedTitle": "{count} participants without class",
|
||||
"problemUnassignedDescription": "These participants still need a manual class assignment.",
|
||||
"problemUnassignedAutoDescription": "{count} of them can be assigned automatically right away.",
|
||||
|
||||
@@ -483,6 +483,9 @@ export default {
|
||||
tournamentWideIsDoubles() {
|
||||
return Boolean(this.currentTournamentWideIsDoubles);
|
||||
},
|
||||
hasTournamentClasses() {
|
||||
return Array.isArray(this.tournamentClasses) && this.tournamentClasses.length > 0;
|
||||
},
|
||||
readyParticipantCount() {
|
||||
return this.totalParticipantCount - this.participantConflictCount - this.unassignedParticipantCount;
|
||||
},
|
||||
@@ -493,7 +496,7 @@ export default {
|
||||
return this.panelTitle;
|
||||
},
|
||||
tournamentConfigurationComplete() {
|
||||
return Boolean(this.currentTournamentDate && (this.currentTournamentName || '').trim() && this.currentWinningSets >= 1 && this.tournamentClasses.length > 0);
|
||||
return Boolean(this.currentTournamentDate && (this.currentTournamentName || '').trim() && this.currentWinningSets >= 1);
|
||||
},
|
||||
groupsFinished() {
|
||||
return this.groupMatches.length > 0 && this.groupMatches.every(match => match.isFinished);
|
||||
@@ -520,7 +523,7 @@ export default {
|
||||
: this.$t('tournaments.statusConfigIncomplete')
|
||||
});
|
||||
|
||||
if (this.unassignedParticipantCount > 0) {
|
||||
if (this.hasTournamentClasses && this.unassignedParticipantCount > 0) {
|
||||
statuses.push({
|
||||
key: 'unassigned',
|
||||
tone: 'warning',
|
||||
@@ -660,7 +663,7 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.unassignedParticipantCount > 0) {
|
||||
if (this.hasTournamentClasses && this.unassignedParticipantCount > 0) {
|
||||
problems.push({
|
||||
key: 'unassigned',
|
||||
priority: 20,
|
||||
|
||||
Reference in New Issue
Block a user