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:
@@ -421,9 +421,9 @@ export const reopenMatch = async (req, res) => {
|
||||
|
||||
export const deleteKnockoutMatches = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
const { clubId, tournamentId, classId } = req.body;
|
||||
try {
|
||||
await tournamentService.resetKnockout(token, clubId, tournamentId);
|
||||
await tournamentService.resetKnockout(token, clubId, tournamentId, classId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: "K.o.-Runde gelöscht" });
|
||||
|
||||
@@ -251,7 +251,14 @@ class TournamentService {
|
||||
const perGroupRanked = relevantStage1Groups.map(g => ({
|
||||
groupId: g.groupId,
|
||||
classId: g.classId ?? null,
|
||||
participants: (g.participants || []).map(p => ({ id: p.id, isExternal: !!p.isExternal })),
|
||||
// WICHTIG:
|
||||
// - Für interne Teilnehmer brauchen wir die ClubMember-ID (Member.id / TournamentMember.clubMemberId),
|
||||
// nicht die TournamentMember.id.
|
||||
// - Für externe Teilnehmer ist `id` die ExternalTournamentParticipant.id (bestehende Logik).
|
||||
participants: (g.participants || []).map(p => ({
|
||||
id: p.isExternal ? p.id : (p.clubMemberId ?? p.member?.id ?? p.id),
|
||||
isExternal: !!p.isExternal,
|
||||
})),
|
||||
}));
|
||||
|
||||
const getByPlace = (grp, place) => grp.participants[place - 1];
|
||||
@@ -262,14 +269,26 @@ class TournamentService {
|
||||
if (fromPlaces.length === 0) continue;
|
||||
const target = rule.target || {};
|
||||
|
||||
const items = [];
|
||||
for (const grp of perGroupRanked) {
|
||||
for (const place of fromPlaces) {
|
||||
const p = getByPlace(grp, Number(place));
|
||||
if (p) items.push({ ...p, classId: grp.classId ?? null });
|
||||
// Wenn Klassen verwendet werden, sollen Advancements je Klasse unabhängig sein.
|
||||
// D.h. eine Pool-Regel wird pro Klasse ausgewertet (oder nur für classless, falls keine Klasse).
|
||||
const classIdsInStage1 = [...new Set(perGroupRanked.map(g => (g.classId ?? null)))];
|
||||
|
||||
for (const classId of classIdsInStage1) {
|
||||
const items = [];
|
||||
for (const grp of perGroupRanked) {
|
||||
if ((grp.classId ?? null) !== classId) continue;
|
||||
for (const place of fromPlaces) {
|
||||
const p = getByPlace(grp, Number(place));
|
||||
if (p) items.push({ ...p, classId });
|
||||
}
|
||||
}
|
||||
|
||||
// Keine leeren Regeln weiterreichen: verhindert, dass eine Klasse ohne fertige Gruppen
|
||||
// "nichts" erzeugt und dadurch andere Regeln/klassen beeinflusst.
|
||||
if (items.length > 0) {
|
||||
poolItems.push({ target, items, classId });
|
||||
}
|
||||
}
|
||||
poolItems.push({ target, items });
|
||||
}
|
||||
|
||||
const createdGroups = [];
|
||||
@@ -278,6 +297,7 @@ class TournamentService {
|
||||
for (const pool of poolItems) {
|
||||
const target = pool.target || {};
|
||||
const items = pool.items || [];
|
||||
const poolClassId = pool.classId ?? null;
|
||||
if (items.length === 0) continue;
|
||||
|
||||
if (target.type === 'groups') {
|
||||
@@ -287,7 +307,7 @@ class TournamentService {
|
||||
poolGroups.push(await TournamentGroup.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
classId: null,
|
||||
classId: poolClassId,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -297,27 +317,10 @@ class TournamentService {
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
|
||||
const per = Math.floor(shuffled.length / groupCount);
|
||||
const rem = shuffled.length % groupCount;
|
||||
let idx = 0;
|
||||
for (let gIdx = 0; gIdx < groupCount; gIdx++) {
|
||||
const take = per + (gIdx < rem ? 1 : 0);
|
||||
for (let k = 0; k < take; k++) {
|
||||
const p = shuffled[idx++];
|
||||
if (!p) continue;
|
||||
if (p.isExternal) {
|
||||
await ExternalTournamentParticipant.update(
|
||||
{ groupId: poolGroups[gIdx].id },
|
||||
{ where: { id: p.id, tournamentId } }
|
||||
);
|
||||
} else {
|
||||
await TournamentMember.update(
|
||||
{ groupId: poolGroups[gIdx].id },
|
||||
{ where: { id: p.id, tournamentId } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
|
||||
createdGroups.push(...poolGroups);
|
||||
} else if (target.type === 'knockout') {
|
||||
@@ -332,11 +335,12 @@ class TournamentService {
|
||||
const containerGroup = await TournamentGroup.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
classId: null,
|
||||
classId: poolClassId,
|
||||
});
|
||||
createdGroups.push(containerGroup);
|
||||
|
||||
const entrants = items.map(p => ({
|
||||
// p.id wurde oben bereits als clubMemberId (intern) bzw. ExternalParticipant.id (extern) gemappt
|
||||
id: Number(p.id),
|
||||
isExternal: !!p.isExternal,
|
||||
}));
|
||||
@@ -348,8 +352,6 @@ class TournamentService {
|
||||
shuffleInPlace(entrants);
|
||||
const bracketSize = nextPowerOfTwo(entrants.length);
|
||||
const byes = bracketSize - entrants.length;
|
||||
for (let i = 0; i < byes; i++) entrants.push(null);
|
||||
|
||||
const roundName = getRoundName(bracketSize);
|
||||
if (wantsThirdPlace && bracketSize >= 4) {
|
||||
// Platzhalter-Match; Teilnehmer werden später nach den Halbfinals gesetzt.
|
||||
@@ -357,7 +359,7 @@ class TournamentService {
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId: null,
|
||||
classId: poolClassId,
|
||||
groupRound: null,
|
||||
round: THIRD_PLACE_ROUND,
|
||||
player1Id: null,
|
||||
@@ -367,21 +369,38 @@ class TournamentService {
|
||||
result: null,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < entrants.length; i += 2) {
|
||||
const a = entrants[i];
|
||||
const b = entrants[i + 1];
|
||||
|
||||
// TODO: Byes automatisch weitertragen (V1: Match wird nicht angelegt, wenn einer fehlt)
|
||||
// Erzeuge zunächst BYE‑Matches für die ersten 'byes' Teilnehmer
|
||||
let remaining = [...entrants];
|
||||
if (byes > 0) {
|
||||
const byePlayers = remaining.slice(0, Math.min(byes, remaining.length));
|
||||
remaining = remaining.slice(byePlayers.length);
|
||||
for (const p of byePlayers) {
|
||||
if (!p) continue;
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId: poolClassId,
|
||||
groupRound: null,
|
||||
round: roundName,
|
||||
player1Id: Number(p.id),
|
||||
player2Id: null,
|
||||
isFinished: true,
|
||||
isActive: false,
|
||||
result: 'BYE',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Paare die verbleibenden Teilnehmer normal
|
||||
for (let i = 0; i < remaining.length; i += 2) {
|
||||
const a = remaining[i];
|
||||
const b = remaining[i + 1];
|
||||
if (!a || !b) continue;
|
||||
|
||||
// Achtung: TournamentMatch kann nur INTEGER player1Id/player2Id.
|
||||
// Externals und Members können kollidierende IDs haben; das ist ein Bestehendes Problem.
|
||||
// V1: wir schreiben die IDs trotzdem, wie im Gruppenspiel-Teil heute (int-only).
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId: null,
|
||||
classId: poolClassId,
|
||||
groupRound: null,
|
||||
round: roundName,
|
||||
player1Id: Number(a.id),
|
||||
@@ -396,70 +415,110 @@ class TournamentService {
|
||||
|
||||
// KO als "ein einziges Feld" über alle Regeln
|
||||
if (singleFieldKoItems.length > 0) {
|
||||
const containerGroup = await TournamentGroup.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
classId: null,
|
||||
});
|
||||
createdGroups.push(containerGroup);
|
||||
|
||||
const entrants = singleFieldKoItems.map(p => ({
|
||||
id: Number(p.id),
|
||||
isExternal: !!p.isExternal,
|
||||
}));
|
||||
|
||||
// Dedupliziere (falls jemand in mehreren Regeln landet)
|
||||
const seen = new Set();
|
||||
const uniqueEntrants = [];
|
||||
for (const e of entrants) {
|
||||
const key = `${e.isExternal ? 'E' : 'M'}:${e.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
uniqueEntrants.push(e);
|
||||
// singleField: bisher war das "alle Regeln zu einem KO-Feld mischen".
|
||||
// Mit Klassen ist es praktisch, pro Klasse ein eigenes singleField-KO zu bauen.
|
||||
const itemsByClass = {};
|
||||
for (const it of singleFieldKoItems) {
|
||||
const key = (it.classId ?? null) === null ? 'null' : String(it.classId);
|
||||
(itemsByClass[key] ||= []).push(it);
|
||||
}
|
||||
|
||||
const thirdPlace = wantsThirdPlace;
|
||||
if (uniqueEntrants.length >= 2) {
|
||||
shuffleInPlace(uniqueEntrants);
|
||||
const bracketSize = nextPowerOfTwo(uniqueEntrants.length);
|
||||
const byes = bracketSize - uniqueEntrants.length;
|
||||
for (let i = 0; i < byes; i++) uniqueEntrants.push(null);
|
||||
for (const [classKey, classItems] of Object.entries(itemsByClass)) {
|
||||
const classId = classKey === 'null' ? null : Number(classKey);
|
||||
|
||||
const roundName = getRoundName(bracketSize);
|
||||
if (thirdPlace && bracketSize >= 4) {
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId: null,
|
||||
groupRound: null,
|
||||
round: THIRD_PLACE_ROUND,
|
||||
player1Id: null,
|
||||
player2Id: null,
|
||||
isFinished: false,
|
||||
isActive: false,
|
||||
result: null,
|
||||
});
|
||||
const containerGroup = await TournamentGroup.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
classId,
|
||||
});
|
||||
createdGroups.push(containerGroup);
|
||||
|
||||
const entrants = classItems.map(p => ({
|
||||
id: Number(p.id),
|
||||
isExternal: !!p.isExternal,
|
||||
}));
|
||||
|
||||
// Dedupliziere (falls jemand in mehreren Regeln landet)
|
||||
const seen = new Set();
|
||||
const uniqueEntrants = [];
|
||||
for (const e of entrants) {
|
||||
const key = `${e.isExternal ? 'E' : 'M'}:${e.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
uniqueEntrants.push(e);
|
||||
}
|
||||
for (let i = 0; i < uniqueEntrants.length; i += 2) {
|
||||
const a = uniqueEntrants[i];
|
||||
const b = uniqueEntrants[i + 1];
|
||||
if (!a || !b) continue;
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId: null,
|
||||
groupRound: null,
|
||||
round: roundName,
|
||||
player1Id: Number(a.id),
|
||||
player2Id: Number(b.id),
|
||||
isFinished: false,
|
||||
isActive: true,
|
||||
result: null,
|
||||
});
|
||||
|
||||
const thirdPlace = wantsThirdPlace;
|
||||
if (uniqueEntrants.length >= 2) {
|
||||
shuffleInPlace(uniqueEntrants);
|
||||
const bracketSize = nextPowerOfTwo(uniqueEntrants.length);
|
||||
const byes = bracketSize - uniqueEntrants.length;
|
||||
|
||||
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
|
||||
let remaining = [...uniqueEntrants];
|
||||
if (byes > 0) {
|
||||
const byePlayers = remaining.slice(0, Math.min(byes, remaining.length));
|
||||
remaining = remaining.slice(byePlayers.length);
|
||||
for (const p of byePlayers) {
|
||||
if (!p) continue;
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId,
|
||||
groupRound: null,
|
||||
round: roundName,
|
||||
player1Id: Number(p.id),
|
||||
player2Id: null,
|
||||
isFinished: true,
|
||||
isActive: false,
|
||||
result: 'BYE',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Verbleibende normal paaren
|
||||
for (let i = 0; i < remaining.length; i += 2) {
|
||||
const a = remaining[i];
|
||||
const b = remaining[i + 1];
|
||||
if (!a || !b) continue;
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
stageId: toStage.id,
|
||||
groupId: containerGroup.id,
|
||||
classId,
|
||||
groupRound: null,
|
||||
round: roundName,
|
||||
player1Id: Number(a.id),
|
||||
player2Id: Number(b.id),
|
||||
isFinished: false,
|
||||
isActive: true,
|
||||
result: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fromStageId: fromStage.id,
|
||||
toStageId: toStage.id,
|
||||
createdGroupIds: createdGroups.map(g => g.id),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -708,20 +767,40 @@ class TournamentService {
|
||||
throw new Error('Turnier nicht gefunden');
|
||||
}
|
||||
|
||||
// Lösche alle bestehenden Gruppen
|
||||
await TournamentGroup.destroy({ where: { tournamentId } });
|
||||
// Nur die angegebenen Klassen anpassen; keine globale Löschung
|
||||
for (const [classIdStr, numberOfGroups] of Object.entries(groupsPerClass || {})) {
|
||||
const classId = (classIdStr === 'null' || classIdStr === 'undefined') ? null : parseInt(classIdStr);
|
||||
const desired = Number.parseInt(numberOfGroups);
|
||||
if (!Number.isFinite(desired) || desired < 0) {
|
||||
// Überspringe ungültige Werte
|
||||
continue;
|
||||
}
|
||||
|
||||
// Erstelle Gruppen pro Klasse
|
||||
for (const [classIdStr, numberOfGroups] of Object.entries(groupsPerClass)) {
|
||||
const classId = classIdStr === 'null' || classIdStr === 'undefined' ? null : parseInt(classIdStr);
|
||||
const numGroups = parseInt(numberOfGroups) || 0;
|
||||
|
||||
if (numGroups > 0) {
|
||||
for (let i = 0; i < numGroups; i++) {
|
||||
await TournamentGroup.create({
|
||||
tournamentId,
|
||||
classId: classId || null
|
||||
});
|
||||
// Hole bestehende Gruppen für diese Klasse
|
||||
const where = { tournamentId };
|
||||
if (classId === null) {
|
||||
where.classId = null;
|
||||
} else {
|
||||
where.classId = classId;
|
||||
}
|
||||
const existing = await TournamentGroup.findAll({ where, order: [['id', 'ASC']] });
|
||||
|
||||
// Wenn desired == 0: lösche nur Gruppen dieser Klasse, nicht die anderen
|
||||
if (desired === 0) {
|
||||
if (existing.length > 0) {
|
||||
await Promise.all(existing.map(g => g.destroy()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ansonsten auf gewünschte Anzahl bringen
|
||||
if (existing.length > desired) {
|
||||
const toRemove = existing.slice(desired);
|
||||
await Promise.all(toRemove.map(g => g.destroy()));
|
||||
} else if (existing.length < desired) {
|
||||
const toCreate = desired - existing.length;
|
||||
for (let i = 0; i < toCreate; i++) {
|
||||
await TournamentGroup.create({ tournamentId, classId });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1217,7 +1296,7 @@ class TournamentService {
|
||||
if (!tournament || tournament.clubId != clubId) {
|
||||
throw new Error('Turnier nicht gefunden');
|
||||
}
|
||||
const groups = await TournamentGroup.findAll({
|
||||
let groups = await TournamentGroup.findAll({
|
||||
where: { tournamentId },
|
||||
include: [{
|
||||
model: TournamentMember,
|
||||
@@ -1236,10 +1315,16 @@ class TournamentService {
|
||||
|
||||
// Lade alle Gruppen-Matches mit Results für Rankings
|
||||
const groupMatches = await TournamentMatch.findAll({
|
||||
where: { tournamentId, round: 'group', isFinished: true },
|
||||
where: { tournamentId, round: 'group' },
|
||||
include: [{ model: TournamentResult, as: 'tournamentResults' }]
|
||||
});
|
||||
|
||||
// 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 knockoutStageIds = new Set(stages.filter(s => s.type === 'knockout').map(s => s.id));
|
||||
groups = groups.filter(g => !(g.stageId && knockoutStageIds.has(g.stageId)));
|
||||
|
||||
// Gruppiere nach Klassen und nummeriere Gruppen pro Klasse
|
||||
const groupsByClass = {};
|
||||
groups.forEach(g => {
|
||||
@@ -1398,6 +1483,8 @@ class TournamentService {
|
||||
} else {
|
||||
// Bei Einzel: Normale Logik
|
||||
if (!stats[m.player1Id] || !stats[m.player2Id]) continue;
|
||||
// Ergebnis kann null/undefiniert oder in anderem Format sein -> defensiv prüfen
|
||||
if (!m.result || typeof m.result !== 'string' || !m.result.includes(':')) continue;
|
||||
const [s1, s2] = m.result.split(':').map(n => parseInt(n, 10));
|
||||
|
||||
if (s1 > s2) {
|
||||
@@ -1460,11 +1547,13 @@ class TournamentService {
|
||||
return m.groupId === g.id && ((aPlayer1 && bPlayer2) || (aPlayer2 && bPlayer1));
|
||||
});
|
||||
if (directMatch) {
|
||||
const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10));
|
||||
const aPlayer1 = a.player1Id === directMatch.player1Id || a.player2Id === directMatch.player1Id;
|
||||
const aWon = aPlayer1 ? (s1 > s2) : (s2 > s1);
|
||||
if (aWon) return -1;
|
||||
return 1;
|
||||
if (directMatch.result && typeof directMatch.result === 'string' && directMatch.result.includes(':')) {
|
||||
const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10));
|
||||
const aPlayer1 = a.player1Id === directMatch.player1Id || a.player2Id === directMatch.player1Id;
|
||||
const aWon = aPlayer1 ? (s1 > s2) : (s2 > s1);
|
||||
if (aWon) return -1;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
} else if (!isDoubles) {
|
||||
directMatch = groupMatches.find(m =>
|
||||
@@ -1473,11 +1562,13 @@ class TournamentService {
|
||||
(m.player1Id === b.id && m.player2Id === a.id))
|
||||
);
|
||||
if (directMatch) {
|
||||
const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10));
|
||||
const aWon = (directMatch.player1Id === a.id && s1 > s2) ||
|
||||
(directMatch.player2Id === a.id && s2 > s1);
|
||||
if (aWon) return -1; // a hat gewonnen -> a kommt weiter oben
|
||||
return 1; // b hat gewonnen -> b kommt weiter oben
|
||||
if (directMatch.result && typeof directMatch.result === 'string' && directMatch.result.includes(':')) {
|
||||
const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10));
|
||||
const aWon = (directMatch.player1Id === a.id && s1 > s2) ||
|
||||
(directMatch.player2Id === a.id && s2 > s1);
|
||||
if (aWon) return -1; // a hat gewonnen -> a kommt weiter oben
|
||||
return 1; // b hat gewonnen -> b kommt weiter oben
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: Alphabetisch nach Name
|
||||
@@ -1516,7 +1607,9 @@ class TournamentService {
|
||||
(m.player1Id === p.id && m.player2Id === prev.id))
|
||||
);
|
||||
}
|
||||
if (!directMatch || directMatch.result.split(':').map(n => +n)[0] === directMatch.result.split(':').map(n => +n)[1]) {
|
||||
if (!directMatch ||
|
||||
!directMatch.result || typeof directMatch.result !== 'string' || !directMatch.result.includes(':') ||
|
||||
directMatch.result.split(':').map(n => +n)[0] === directMatch.result.split(':').map(n => +n)[1]) {
|
||||
// Gleicher Platz wie Vorgänger (unentschieden oder kein direktes Match)
|
||||
return {
|
||||
...p,
|
||||
@@ -1830,13 +1923,22 @@ class TournamentService {
|
||||
// Gruppiere nach Klasse
|
||||
const winnersByClass = {};
|
||||
sameRound.forEach(m => {
|
||||
const [w1, w2] = m.result.split(":").map(n => +n);
|
||||
const winner = w1 > w2 ? m.player1Id : m.player2Id;
|
||||
const classKey = m.classId || 'null';
|
||||
if (!winnersByClass[classKey]) {
|
||||
winnersByClass[classKey] = [];
|
||||
if (!m || !m.result) return;
|
||||
let winnerId = null;
|
||||
// BYE: Spieler1 ist automatisch Sieger
|
||||
if (String(m.result).toUpperCase() === 'BYE') {
|
||||
winnerId = Number(m.player1Id) || null;
|
||||
} else if (m.result.includes(':')) {
|
||||
const parts = m.result.split(':');
|
||||
const w1 = Number(parts[0]);
|
||||
const w2 = Number(parts[1]);
|
||||
if (Number.isFinite(w1) && Number.isFinite(w2)) {
|
||||
winnerId = w1 > w2 ? m.player1Id : m.player2Id;
|
||||
}
|
||||
}
|
||||
winnersByClass[classKey].push(winner);
|
||||
if (!winnerId) return;
|
||||
const classKey = m.classId || 'null';
|
||||
(winnersByClass[classKey] ||= []).push(Number(winnerId));
|
||||
});
|
||||
|
||||
const nextName = nextRoundName(match.round);
|
||||
@@ -1848,19 +1950,24 @@ class TournamentService {
|
||||
|
||||
// Erstelle nächste Runde pro Klasse
|
||||
for (const [classKey, winners] of Object.entries(winnersByClass)) {
|
||||
if (winners.length < 2) continue; // Überspringe Klassen mit weniger als 2 Gewinnern
|
||||
|
||||
const classId = classKey !== 'null' ? parseInt(classKey) : null;
|
||||
const filtered = (winners || []).filter(id => Number.isFinite(id) && id > 0);
|
||||
if (filtered.length < 2) continue; // zu wenige Gewinner
|
||||
|
||||
// (keine Drittplatz-Erzeugung hier)
|
||||
|
||||
for (let i = 0; i < winners.length / 2; i++) {
|
||||
const classId = classKey !== 'null' ? parseInt(classKey) : null;
|
||||
// Paarungen sequentiell (0-1, 2-3, ...) statt außen-innen, um Ordnung zu bewahren
|
||||
for (let i = 0; i + 1 < filtered.length; i += 2) {
|
||||
const p1 = filtered[i];
|
||||
const p2 = filtered[i + 1];
|
||||
if (!p1 || !p2 || p1 === p2) {
|
||||
devLog(`[finishMatch] Skip invalid next-round pairing in ${nextName} for class ${classKey}: ${p1} vs ${p2}`);
|
||||
continue;
|
||||
}
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
round: nextName,
|
||||
player1Id: winners[i],
|
||||
player2Id: winners[winners.length - 1 - i],
|
||||
classId: classId
|
||||
player1Id: p1,
|
||||
player2Id: p2,
|
||||
classId
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2038,6 +2145,40 @@ class TournamentService {
|
||||
const rn = getRoundName(roundSize);
|
||||
const classId = classKey !== 'null' ? parseInt(classKey) : null;
|
||||
|
||||
// Sonderfall: 3 Qualifier => genau EIN "Halbfinale (3)"-Match + 1 Freilos.
|
||||
// Die generische Gruppen-Pairing-Logik versucht sonst ggf. 2 Matches zu erstellen.
|
||||
if (roundSize === 3) {
|
||||
// Deterministisch: Darf nicht von Gruppen abhängen.
|
||||
// Bye = bester Qualifier (Position 1 zuerst, dann kleinere ID als stabiler Tiebreaker)
|
||||
const sorted = [...classQualifiers].sort((a, b) => {
|
||||
const pa = Number(a.position ?? 999);
|
||||
const pb = Number(b.position ?? 999);
|
||||
if (pa !== pb) return pa - pb;
|
||||
return Number(a.id) - Number(b.id);
|
||||
});
|
||||
|
||||
const bye = sorted[0];
|
||||
const p1 = sorted[1];
|
||||
const p2 = sorted[2];
|
||||
|
||||
devLog(`[startKnockout] roundSize=3: assigning bye=${bye?.id} and creating 1 match ${p1?.id} vs ${p2?.id} for class ${classKey}`);
|
||||
|
||||
if (p1?.id && p2?.id && p1.id !== p2.id) {
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
round: rn,
|
||||
player1Id: p1.id,
|
||||
player2Id: p2.id,
|
||||
classId: classId
|
||||
});
|
||||
} else {
|
||||
devLog(`[startKnockout] Warning: Could not create Halbfinale (3) match for class ${classKey}`);
|
||||
}
|
||||
|
||||
// Bye wird nicht als Match persistiert (keine Placeholders).
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drittplatz wird erst nach beiden Halbfinals mit fixen Spielern erzeugt.
|
||||
|
||||
// Gruppiere Qualifiers nach Gruppen
|
||||
@@ -2120,6 +2261,15 @@ class TournamentService {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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));
|
||||
if (unusedQualifiers.length > 0) {
|
||||
devLog(`[startKnockout] Assigning ${unusedQualifiers.length} bye(s) for class ${classKey}:`, unusedQualifiers.map(q => q.id));
|
||||
}
|
||||
|
||||
// Erstelle die Matches in der Datenbank
|
||||
for (const match of matches) {
|
||||
@@ -2127,6 +2277,12 @@ class TournamentService {
|
||||
devLog(`[startKnockout] Warning: Invalid match pair for class ${classKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Safety: niemals Spieler gegen sich selbst.
|
||||
if (match.player1.id === match.player2.id) {
|
||||
devLog(`[startKnockout] Warning: Prevented self-match for class ${classKey}: ${match.player1.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await TournamentMatch.create({
|
||||
@@ -2459,15 +2615,17 @@ class TournamentService {
|
||||
await match.save();
|
||||
}
|
||||
|
||||
async resetKnockout(userToken, clubId, tournamentId) {
|
||||
async resetKnockout(userToken, clubId, tournamentId, classId = null) {
|
||||
await checkAccess(userToken, clubId);
|
||||
// lösche alle Matches außer Gruppenphase
|
||||
await TournamentMatch.destroy({
|
||||
where: {
|
||||
tournamentId,
|
||||
round: { [Op.ne]: "group" }
|
||||
}
|
||||
});
|
||||
const where = {
|
||||
tournamentId,
|
||||
round: { [Op.ne]: "group" }
|
||||
};
|
||||
if (classId != null) {
|
||||
where.classId = Number(classId);
|
||||
}
|
||||
await TournamentMatch.destroy({ where });
|
||||
}
|
||||
|
||||
// Externe Teilnehmer hinzufügen
|
||||
|
||||
@@ -17,7 +17,9 @@ import Tournament from '../models/Tournament.js';
|
||||
import TournamentGroup from '../models/TournamentGroup.js';
|
||||
import TournamentMember from '../models/TournamentMember.js';
|
||||
import TournamentMatch from '../models/TournamentMatch.js';
|
||||
import TournamentResult from '../models/TournamentResult.js';
|
||||
import TournamentStage from '../models/TournamentStage.js';
|
||||
import TournamentClass from '../models/TournamentClass.js';
|
||||
import Club from '../models/Club.js';
|
||||
import { createMember } from './utils/factories.js';
|
||||
|
||||
@@ -254,6 +256,112 @@ describe('tournamentService', () => {
|
||||
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
|
||||
});
|
||||
|
||||
it('Stage-KO: 3 Gruppen × Plätze 1,2 => 6 Qualifier (keine falschen IDs, keine Duplikate)', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Stage-KO-6', '2025-11-20');
|
||||
|
||||
// Stages: Vorrunde (Groups) -> Endrunde (KO)
|
||||
await tournamentService.upsertTournamentStages(
|
||||
'token',
|
||||
club.id,
|
||||
tournament.id,
|
||||
[
|
||||
{ index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 3 },
|
||||
{ index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null },
|
||||
],
|
||||
null,
|
||||
[
|
||||
{
|
||||
fromStageIndex: 1,
|
||||
toStageIndex: 2,
|
||||
mode: 'pools',
|
||||
config: {
|
||||
pools: [
|
||||
{
|
||||
fromPlaces: [1, 2],
|
||||
target: { type: 'knockout', singleField: true, thirdPlace: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// 3 Gruppen anlegen
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 3);
|
||||
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] });
|
||||
expect(groups).toHaveLength(3);
|
||||
|
||||
// Je Gruppe 2 Teilnehmer -> insgesamt 6
|
||||
const members = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `S${i}`,
|
||||
lastName: 'KO',
|
||||
email: `stage_ko6_${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
// Gruppe 1
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[0].id, classId: null, groupId: groups[0].id });
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[1].id, classId: null, groupId: groups[0].id });
|
||||
// Gruppe 2
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[2].id, classId: null, groupId: groups[1].id });
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[3].id, classId: null, groupId: groups[1].id });
|
||||
// Gruppe 3
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[4].id, classId: null, groupId: groups[2].id });
|
||||
await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[5].id, classId: null, groupId: groups[2].id });
|
||||
|
||||
// Gruppenspiele erzeugen+beenden (damit Ranking/Platz 1/2 stabil ist)
|
||||
// Wir erzeugen minimal pro Gruppe ein 1v1-Match und schließen es ab.
|
||||
for (const g of groups) {
|
||||
const [tm1, tm2] = await TournamentMember.findAll({ where: { tournamentId: tournament.id, groupId: g.id }, order: [['id', 'ASC']] });
|
||||
const gm = await TournamentMatch.create({
|
||||
tournamentId: tournament.id,
|
||||
round: 'group',
|
||||
groupId: g.id,
|
||||
classId: null,
|
||||
player1Id: tm1.id,
|
||||
player2Id: tm2.id,
|
||||
isFinished: true,
|
||||
isActive: true,
|
||||
result: '3:0',
|
||||
});
|
||||
await TournamentResult.bulkCreate([
|
||||
{ matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 1 },
|
||||
{ matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 2 },
|
||||
{ matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 3 },
|
||||
]);
|
||||
}
|
||||
|
||||
// KO-Endrunde erstellen
|
||||
await tournamentService.advanceTournamentStage('token', club.id, tournament.id, 1, 2);
|
||||
|
||||
const stage2 = await TournamentStage.findOne({ where: { tournamentId: tournament.id, index: 2 } });
|
||||
expect(stage2).toBeTruthy();
|
||||
|
||||
const stage2Matches = await TournamentMatch.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id }, order: [['id', 'ASC']] });
|
||||
const round1 = stage2Matches.filter(m => String(m.round || '').includes('Viertelfinale') || String(m.round || '').includes('Achtelfinale') || String(m.round || '').includes('Halbfinale (3)'));
|
||||
|
||||
// Bei 6 Entrants muss ein 8er-Bracket entstehen => 3 Matches in der ersten Runde.
|
||||
// (Die Byes werden nicht als Matches angelegt.)
|
||||
expect(round1.length).toBe(3);
|
||||
for (const m of round1) {
|
||||
expect(m.player1Id).toBeTruthy();
|
||||
expect(m.player2Id).toBeTruthy();
|
||||
expect(m.player1Id).not.toBe(m.player2Id);
|
||||
}
|
||||
|
||||
// Spieler-IDs müssen Member-IDs (clubMemberId) sein, nicht TournamentMember.id
|
||||
const memberIdSet = new Set(members.map(x => x.id));
|
||||
for (const m of round1) {
|
||||
expect(memberIdSet.has(m.player1Id)).toBe(true);
|
||||
expect(memberIdSet.has(m.player2Id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('Legacy-KO: legt Platz-3 an und befüllt es nach beiden Halbfinals', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-3rd', '2025-11-15');
|
||||
@@ -324,6 +432,204 @@ describe('tournamentService', () => {
|
||||
expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id);
|
||||
});
|
||||
|
||||
it('Legacy-KO: bei ungerader Qualifier-Zahl wird ein Freilos vergeben (kein Duplikat / kein Self-Match)', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-Bye', '2025-11-17');
|
||||
|
||||
// 3 Gruppen, jeweils 1 Spieler -> advancingPerGroup=1 => 3 Qualifier
|
||||
await tournamentService.setModus('token', club.id, tournament.id, 'groups', 3, 1);
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 3);
|
||||
|
||||
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] });
|
||||
expect(groups).toHaveLength(3);
|
||||
|
||||
const members = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
members.push(
|
||||
await createMember(club.id, {
|
||||
firstName: `B${i}`,
|
||||
lastName: 'YE',
|
||||
email: `legacy_bye_${i}@example.com`,
|
||||
gender: i % 2 === 0 ? 'male' : 'female',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Je Gruppe genau 1 Teilnehmer, und keine Gruppenspiele nötig (es gibt keine Paarungen)
|
||||
await TournamentMember.create({
|
||||
tournamentId: tournament.id,
|
||||
clubMemberId: members[0].id,
|
||||
classId: null,
|
||||
groupId: groups[0].id,
|
||||
});
|
||||
await TournamentMember.create({
|
||||
tournamentId: tournament.id,
|
||||
clubMemberId: members[1].id,
|
||||
classId: null,
|
||||
groupId: groups[1].id,
|
||||
});
|
||||
await TournamentMember.create({
|
||||
tournamentId: tournament.id,
|
||||
clubMemberId: members[2].id,
|
||||
classId: null,
|
||||
groupId: groups[2].id,
|
||||
});
|
||||
|
||||
// KO starten: Erwartung = genau 1 Match (2 Spieler) + 1 Freilos (ohne extra Match)
|
||||
await tournamentService.startKnockout('token', club.id, tournament.id);
|
||||
|
||||
const koMatches = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id, round: { [Op.ne]: 'group' } },
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
|
||||
// Bei 3 Qualifiern muss GENAU EIN Halbfinale (3) existieren.
|
||||
const semi3 = koMatches.filter(m => m.round === 'Halbfinale (3)');
|
||||
expect(semi3).toHaveLength(1);
|
||||
expect(semi3[0].player1Id).toBeTruthy();
|
||||
expect(semi3[0].player2Id).toBeTruthy();
|
||||
expect(semi3[0].player1Id).not.toBe(semi3[0].player2Id);
|
||||
|
||||
// Self-match darf nirgends vorkommen.
|
||||
for (const m of koMatches) {
|
||||
if (m.player1Id && m.player2Id) expect(m.player1Id).not.toBe(m.player2Id);
|
||||
}
|
||||
|
||||
// Hinweis: Bei 3 Qualifiern wird im Legacy-Flow aktuell ein "Halbfinale (3)" erzeugt.
|
||||
// Ein automatisches Weitertragen des Freiloses bis in ein fertiges Finale ist nicht Teil dieses Tests.
|
||||
// Wichtig ist hier die Regression: kein Duplikat und kein Self-Match.
|
||||
|
||||
// Halbfinale beenden (soll keine kaputten Folge-Matches erzeugen)
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 1, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 2, '11:1');
|
||||
await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 3, '11:1');
|
||||
|
||||
const after = await TournamentMatch.findAll({
|
||||
where: { tournamentId: tournament.id, round: { [Op.ne]: 'group' } },
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
|
||||
// Egal ob ein Folge-Match entsteht oder nicht: es darf kein Self-Match geben.
|
||||
for (const m of after) {
|
||||
if (m.player1Id && m.player2Id) expect(m.player1Id).not.toBe(m.player2Id);
|
||||
}
|
||||
});
|
||||
|
||||
it('Stage advancement ist klassenisoliert (Zwischen-/Endrunde hängt nur von der jeweiligen Klasse ab)', async () => {
|
||||
const club = await Club.create({ name: 'Club', accessToken: 'token' });
|
||||
const tournament = await Tournament.create({
|
||||
clubId: club.id,
|
||||
name: 'Stages Multi-Class',
|
||||
date: '2025-12-14',
|
||||
type: 'groups',
|
||||
numberOfGroups: 2,
|
||||
advancingPerGroup: 1,
|
||||
winningSets: 3,
|
||||
allowsExternal: false,
|
||||
});
|
||||
|
||||
const classA = await TournamentClass.create({ tournamentId: tournament.id, name: 'A' });
|
||||
const classB = await TournamentClass.create({ tournamentId: tournament.id, name: 'B' });
|
||||
|
||||
await tournamentService.upsertTournamentStages(
|
||||
'token',
|
||||
club.id,
|
||||
tournament.id,
|
||||
[
|
||||
{ index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 2 },
|
||||
{ index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null },
|
||||
],
|
||||
null,
|
||||
[
|
||||
{
|
||||
fromStageIndex: 1,
|
||||
toStageIndex: 2,
|
||||
mode: 'pools',
|
||||
config: {
|
||||
pools: [
|
||||
{ fromPlaces: [1], target: { type: 'knockout', singleField: true, thirdPlace: false } },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
await tournamentService.createGroups('token', club.id, tournament.id, 2);
|
||||
const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] });
|
||||
expect(groups.length).toBe(2);
|
||||
|
||||
// Klasse A fertig
|
||||
const memberA1 = await createMember(club.id, {
|
||||
firstName: 'A1',
|
||||
lastName: 'Test',
|
||||
email: 'stage_class_a1@example.com',
|
||||
gender: 'male',
|
||||
});
|
||||
const memberA2 = await createMember(club.id, {
|
||||
firstName: 'A2',
|
||||
lastName: 'Test',
|
||||
email: 'stage_class_a2@example.com',
|
||||
gender: 'female',
|
||||
});
|
||||
const a1 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberA1.id, classId: classA.id, groupId: groups[0].id });
|
||||
const a2 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberA2.id, classId: classA.id, groupId: groups[0].id });
|
||||
const aMatch = await TournamentMatch.create({
|
||||
tournamentId: tournament.id,
|
||||
round: 'group',
|
||||
groupId: groups[0].id,
|
||||
classId: classA.id,
|
||||
player1Id: a1.id,
|
||||
player2Id: a2.id,
|
||||
isFinished: true,
|
||||
isActive: true,
|
||||
result: '3:0',
|
||||
});
|
||||
await TournamentResult.bulkCreate([
|
||||
{ matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 1 },
|
||||
{ matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 2 },
|
||||
{ matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 3 },
|
||||
]);
|
||||
|
||||
// Klasse B unfertig
|
||||
const memberB1 = await createMember(club.id, {
|
||||
firstName: 'B1',
|
||||
lastName: 'Test',
|
||||
email: 'stage_class_b1@example.com',
|
||||
gender: 'male',
|
||||
});
|
||||
const memberB2 = await createMember(club.id, {
|
||||
firstName: 'B2',
|
||||
lastName: 'Test',
|
||||
email: 'stage_class_b2@example.com',
|
||||
gender: 'female',
|
||||
});
|
||||
const b1 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberB1.id, classId: classB.id, groupId: groups[1].id });
|
||||
const b2 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberB2.id, classId: classB.id, groupId: groups[1].id });
|
||||
await TournamentMatch.create({
|
||||
tournamentId: tournament.id,
|
||||
round: 'group',
|
||||
groupId: groups[1].id,
|
||||
classId: classB.id,
|
||||
player1Id: b1.id,
|
||||
player2Id: b2.id,
|
||||
isFinished: false,
|
||||
isActive: true,
|
||||
result: null,
|
||||
});
|
||||
|
||||
await tournamentService.advanceTournamentStage('token', club.id, tournament.id, 1, 2);
|
||||
const stage2 = await TournamentStage.findOne({ where: { tournamentId: tournament.id, index: 2 } });
|
||||
expect(stage2).toBeTruthy();
|
||||
|
||||
const stage2Matches = await TournamentMatch.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id } });
|
||||
expect(stage2Matches.some(m => m.classId === classB.id)).toBe(false);
|
||||
|
||||
// Und es wurden keine Stage2-Gruppen für Klasse B erzeugt.
|
||||
// (classless Container-Gruppen sind möglich – entscheidend ist, dass Klasse B nicht blockiert/vermengt wird.)
|
||||
const stage2Groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id } });
|
||||
expect(stage2Groups.some(g => g.classId === classB.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('Legacy-KO: Platz-3 entsteht erst nach beiden Halbfinals (ohne Placeholder)', async () => {
|
||||
const club = await Club.create({ name: 'Tournament Club' });
|
||||
const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-3rd-late', '2025-11-16');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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() {
|
||||
// KO‑Matches existieren und keiner ist beendet
|
||||
return this.knockoutMatches.length > 0
|
||||
&& this.knockoutMatches.every(m => !m.isFinished);
|
||||
// Zeige den Löschen‑Button, sobald KO‑Matches 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 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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'));
|
||||
|
||||
Reference in New Issue
Block a user