feat(tournament): add group match creation and enhance match handling

- Implemented createGroupMatches function to generate matches for existing groups without altering group assignments.
- Updated resetMatches function to support optional class filtering when resetting group matches.
- Enhanced frontend components to filter and display group matches based on selected class, improving user experience.
- Adjusted tournament results display to reflect accurate match statistics, including wins and losses.
This commit is contained in:
Torsten Schulz (local)
2025-12-17 13:38:40 +01:00
parent 4b4c48a50f
commit dc084806ab
7 changed files with 676 additions and 186 deletions

3
.gitignore vendored
View File

@@ -7,4 +7,5 @@ backend/.env
backend/images/*
backend/backend-debug.log
backend/*.log
backend/*.log
backend/.env.local

View File

@@ -174,6 +174,21 @@ export const fillGroups = async (req, res) => {
}
};
// 7b. Gruppenspiele erstellen ohne Gruppenzuordnungen zu ändern
export const createGroupMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.body;
try {
await tournamentService.createGroupMatches(token, clubId, tournamentId, classId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 8. Gruppen mit ihren Teilnehmern abfragen
export const getGroups = async (req, res) => {
const { authcode: token } = req.headers;
@@ -341,9 +356,9 @@ export const resetGroups = async (req, res) => {
export const resetMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
const { clubId, tournamentId, classId } = req.body;
try {
await tournamentService.resetMatches(token, clubId, tournamentId);
await tournamentService.resetMatches(token, clubId, tournamentId, classId || null);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);

View File

@@ -8,6 +8,7 @@ import {
setModus,
createGroups,
fillGroups,
createGroupMatches,
getGroups,
getTournament,
getTournamentMatches,
@@ -58,6 +59,7 @@ router.post('/matches/reset', authenticate, resetMatches);
router.put('/groups', authenticate, createGroups);
router.post('/groups/create', authenticate, createGroupsPerClass);
router.post('/groups', authenticate, fillGroups);
router.post('/matches/create', authenticate, createGroupMatches);
router.get('/groups', authenticate, getGroups);
router.post('/match/result', authenticate, addMatchResult);
router.delete('/match/result', authenticate, deleteMatchResult);

View File

@@ -274,12 +274,22 @@ class TournamentService {
const classIdsInStage1 = [...new Set(perGroupRanked.map(g => (g.classId ?? null)))];
for (const classId of classIdsInStage1) {
// Zähle die Gruppen für diese Klasse
const groupsForClass = perGroupRanked.filter(g => (g.classId ?? null) === classId);
const numberOfGroupsForClass = groupsForClass.length;
// Nur Klassen mit mehr als einer Gruppe dürfen weitere Runden haben
if (numberOfGroupsForClass <= 1) {
// Überspringe diese Klasse - keine Zwischen-/Endrunde für Klassen mit nur einer Gruppe
continue;
}
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 });
if (p) items.push({ ...p, classId, place: Number(place) });
}
}
@@ -295,11 +305,20 @@ class TournamentService {
const singleFieldKoItems = [];
let wantsThirdPlace = false;
for (const pool of poolItems) {
const target = pool.target || {};
let target = pool.target || {};
const items = pool.items || [];
const poolClassId = pool.classId ?? null;
if (items.length === 0) continue;
// Wenn Endrunde K.O. sein soll, aber target.type === 'groups' ist,
// dann sollte es K.O. sein statt Gruppen.
// Die Stage-Konfiguration (toStage.type) ist die Quelle der Wahrheit
if (toStage.type === 'knockout' && target.type === 'groups') {
// Erzwinge K.O. wenn die Stage-Konfiguration K.O. ist
// singleField sollte true sein für Endrunden (K.O. als ein einziges Feld)
target = { ...target, type: 'knockout', singleField: true };
}
if (target.type === 'groups') {
const groupCount = Math.max(1, Number(target.groupCount || toStage.numberOfGroups || 1));
const poolGroups = [];
@@ -436,24 +455,42 @@ class TournamentService {
const entrants = classItems.map(p => ({
id: Number(p.id),
isExternal: !!p.isExternal,
place: p.place || 999, // Platz-Information behalten
}));
// Dedupliziere (falls jemand in mehreren Regeln landet)
const seen = new Set();
const uniqueEntrants = [];
// Wenn jemand mehrfach vorkommt, nehme den besten Platz
const seen = new Map();
for (const e of entrants) {
const key = `${e.isExternal ? 'E' : 'M'}:${e.id}`;
if (seen.has(key)) continue;
seen.add(key);
uniqueEntrants.push(e);
const existing = seen.get(key);
if (!existing || (e.place < existing.place)) {
seen.set(key, e);
}
}
const uniqueEntrants = Array.from(seen.values());
const thirdPlace = wantsThirdPlace;
if (uniqueEntrants.length >= 2) {
shuffleInPlace(uniqueEntrants);
// Sortiere nach Platz: beste Plätze zuerst, dann schlechtere
// Wenn mehrere Teilnehmer den gleichen Platz haben, behalte die ursprüngliche Reihenfolge
uniqueEntrants.sort((a, b) => {
const placeA = a.place || 999;
const placeB = b.place || 999;
return placeA - placeB;
});
// Paare: Bester gegen Schlechtesten, Zweiter gegen Vorletzten, etc.
// Reverse die zweite Hälfte, um das gewünschte Pairing zu erreichen
const bracketSize = nextPowerOfTwo(uniqueEntrants.length);
const byes = bracketSize - uniqueEntrants.length;
const playersNeeded = bracketSize - byes;
// Teile in zwei Hälften für das Pairing
const firstHalf = uniqueEntrants.slice(0, Math.ceil(playersNeeded / 2));
const secondHalf = uniqueEntrants.slice(Math.ceil(playersNeeded / 2), playersNeeded);
const reversedSecondHalf = [...secondHalf].reverse(); // Umgekehrte Reihenfolge für Pairing
const roundName = getRoundName(bracketSize);
if (thirdPlace && bracketSize >= 4) {
await TournamentMatch.create({
@@ -470,12 +507,12 @@ class TournamentService {
result: null,
});
}
// BYEMatches zuerst
let remaining = [...uniqueEntrants];
// BYEMatches zuerst (für die ersten Teilnehmer, wenn nötig)
if (byes > 0) {
const byePlayers = remaining.slice(0, Math.min(byes, remaining.length));
remaining = remaining.slice(byePlayers.length);
for (const p of byePlayers) {
// Die besten Spieler bekommen BYEs
for (let i = 0; i < byes && i < uniqueEntrants.length; i++) {
const p = uniqueEntrants[i];
if (!p) continue;
await TournamentMatch.create({
tournamentId,
@@ -492,10 +529,12 @@ class TournamentService {
});
}
}
// Verbleibende normal paaren
for (let i = 0; i < remaining.length; i += 2) {
const a = remaining[i];
const b = remaining[i + 1];
// Paare: Erster gegen Letzten, Zweiter gegen Vorletzten, etc.
const maxPairs = Math.min(firstHalf.length, reversedSecondHalf.length);
for (let i = 0; i < maxPairs; i++) {
const a = firstHalf[i];
const b = reversedSecondHalf[i];
if (!a || !b) continue;
await TournamentMatch.create({
tournamentId,
@@ -1278,6 +1317,142 @@ class TournamentService {
return allParticipants;
}
// Erstellt nur die Matches für bestehende Gruppen, ohne Gruppenzuordnungen zu ändern
async createGroupMatches(userToken, clubId, tournamentId, classId = null) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
// Lade alle Gruppen, optional gefiltert nach classId
const whereClause = { tournamentId };
if (classId !== null && classId !== undefined) {
whereClause.classId = classId;
}
const allGroups = await TournamentGroup.findAll({
where: whereClause,
order: [['id', 'ASC']]
});
if (allGroups.length === 0) {
throw new Error('Keine Gruppen vorhanden.');
}
// Lösche nur Matches für die betroffenen Gruppen (optional gefiltert nach classId)
const matchWhereClause = {
tournamentId,
round: 'group'
};
if (classId !== null && classId !== undefined) {
matchWhereClause.classId = classId;
}
await TournamentMatch.destroy({ where: matchWhereClause });
// Lade alle Klassen, um zu prüfen, ob es sich um Doppel-Klassen handelt
const tournamentClasses = await TournamentClass.findAll({ where: { tournamentId } });
const classIsDoublesMap = tournamentClasses.reduce((map, cls) => {
map[cls.id] = cls.isDoubles;
return map;
}, {});
for (const g of allGroups) {
const groupClassId = g.classId;
const isDoubles = groupClassId ? (classIsDoublesMap[groupClassId] || false) : false;
if (isDoubles) {
// Bei Doppel: Lade Paarungen für diese Gruppe
const pairings = await TournamentPairing.findAll({
where: { tournamentId, classId: groupClassId, groupId: g.id },
include: [
{ model: TournamentMember, as: 'member1', include: [{ model: Member, as: 'member' }] },
{ model: TournamentMember, as: 'member2', include: [{ model: Member, as: 'member' }] },
{ model: ExternalTournamentParticipant, as: 'external1' },
{ model: ExternalTournamentParticipant, as: 'external2' }
]
});
if (pairings.length < 2) {
continue;
}
// Erstelle Round-Robin zwischen Paarungen
const pairingItems = pairings.map(p => ({
pairingId: p.id,
player1Id: p.member1Id || p.external1Id,
player2Id: p.member2Id || p.external2Id,
key: `pairing-${p.id}`
}));
const rounds = this.generateRoundRobinSchedule(pairingItems.map(p => ({ id: p.key })));
for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) {
for (const [p1Key, p2Key] of rounds[roundIndex]) {
if (p1Key && p2Key) {
const pairing1 = pairingItems.find(p => p.key === p1Key);
const pairing2 = pairingItems.find(p => p.key === p2Key);
if (pairing1 && pairing2) {
await TournamentMatch.create({
tournamentId,
groupId: g.id,
round: 'group',
player1Id: pairing1.player1Id,
player2Id: pairing2.player1Id,
groupRound: roundIndex + 1,
classId: g.classId
});
}
}
}
}
} else {
// Bei Einzel: Normale Logik mit einzelnen Spielern
const internalMembers = await TournamentMember.findAll({ where: { groupId: g.id } });
const externalMembers = await ExternalTournamentParticipant.findAll({ where: { groupId: g.id } });
const allGroupMembers = [
...internalMembers.map(m => ({ id: m.id, isExternal: false, key: `internal-${m.id}` })),
...externalMembers.map(m => ({ id: m.id, isExternal: true, key: `external-${m.id}` }))
];
const memberMap = new Map();
allGroupMembers.forEach(m => {
memberMap.set(m.key, m);
});
if (allGroupMembers.length < 2) {
continue;
}
const rounds = this.generateRoundRobinSchedule(allGroupMembers.map(m => ({ id: m.key })));
for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) {
for (const [p1Key, p2Key] of rounds[roundIndex]) {
if (p1Key && p2Key) {
const p1 = memberMap.get(p1Key);
const p2 = memberMap.get(p2Key);
if (p1 && p2) {
await TournamentMatch.create({
tournamentId,
groupId: g.id,
round: 'group',
player1Id: p1.id,
player2Id: p2.id,
groupRound: roundIndex + 1,
classId: g.classId
});
}
}
}
}
}
}
}
async getGroups(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
@@ -1400,7 +1575,9 @@ class TournamentService {
setsLost: 0,
pointsWon: 0,
pointsLost: 0,
pointRatio: 0
pointRatio: 0,
matchesWon: 0,
matchesLost: 0
};
}
} else {
@@ -1417,7 +1594,9 @@ class TournamentService {
setsLost: 0,
pointsWon: 0,
pointsLost: 0,
pointRatio: 0
pointRatio: 0,
matchesWon: 0,
matchesLost: 0
};
}
@@ -1433,7 +1612,9 @@ class TournamentService {
setsLost: 0,
pointsWon: 0,
pointsLost: 0,
pointRatio: 0
pointRatio: 0,
matchesWon: 0,
matchesLost: 0
};
}
}
@@ -1458,10 +1639,14 @@ class TournamentService {
if (s1 > s2) {
stats[pairing1Key].points += 1;
stats[pairing1Key].matchesWon += 1;
stats[pairing2Key].points -= 1;
stats[pairing2Key].matchesLost += 1;
} else if (s2 > s1) {
stats[pairing2Key].points += 1;
stats[pairing2Key].matchesWon += 1;
stats[pairing1Key].points -= 1;
stats[pairing1Key].matchesLost += 1;
}
stats[pairing1Key].setsWon += s1;
@@ -1473,8 +1658,9 @@ class TournamentService {
if (m.tournamentResults && m.tournamentResults.length > 0) {
let p1Points = 0, p2Points = 0;
for (const r of m.tournamentResults) {
p1Points += r.pointsPlayer1 || 0;
p2Points += r.pointsPlayer2 || 0;
// Verwende absolute Werte, falls negative Werte gespeichert wurden
p1Points += Math.abs(r.pointsPlayer1 || 0);
p2Points += Math.abs(r.pointsPlayer2 || 0);
}
stats[pairing1Key].pointsWon += p1Points;
stats[pairing1Key].pointsLost += p2Points;
@@ -1490,10 +1676,14 @@ class TournamentService {
if (s1 > s2) {
stats[m.player1Id].points += 1;
stats[m.player1Id].matchesWon += 1;
stats[m.player2Id].points -= 1;
stats[m.player2Id].matchesLost += 1;
} else if (s2 > s1) {
stats[m.player2Id].points += 1;
stats[m.player2Id].matchesWon += 1;
stats[m.player1Id].points -= 1;
stats[m.player1Id].matchesLost += 1;
}
stats[m.player1Id].setsWon += s1;
@@ -1505,8 +1695,9 @@ class TournamentService {
if (m.tournamentResults && m.tournamentResults.length > 0) {
let p1Points = 0, p2Points = 0;
for (const r of m.tournamentResults) {
p1Points += r.pointsPlayer1 || 0;
p2Points += r.pointsPlayer2 || 0;
// Verwende absolute Werte, falls negative Werte gespeichert wurden
p1Points += Math.abs(r.pointsPlayer1 || 0);
p2Points += Math.abs(r.pointsPlayer2 || 0);
}
stats[m.player1Id].pointsWon += p1Points;
stats[m.player1Id].pointsLost += p2Points;
@@ -1692,11 +1883,10 @@ class TournamentService {
await checkAccess(userToken, clubId);
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!t) throw new Error('Turnier nicht gefunden');
// 1) Matches ohne Spieler laden (damit Sequelize nicht automatisch falsche TournamentMember matched)
let matches = await TournamentMatch.findAll({
where: { tournamentId },
include: [
{ model: TournamentMember, as: 'player1', required: false, include: [{ model: Member, as: 'member' }] },
{ model: TournamentMember, as: 'player2', required: false, include: [{ model: Member, as: 'member' }] },
{ model: TournamentResult, as: 'tournamentResults' }
],
order: [
@@ -1725,93 +1915,85 @@ class TournamentService {
if (rA !== rB) return rA - rB;
return (a.id ?? 0) - (b.id ?? 0);
});
// WICHTIG: Lade alle gültigen TournamentMember-IDs und ExternalTournamentParticipant-IDs für dieses Turnier
// um zu prüfen, ob ein geladenes TournamentMember tatsächlich zu diesem Turnier gehört
const validTournamentMemberIds = new Set();
const allTournamentMembers = await TournamentMember.findAll({
where: { tournamentId },
attributes: ['id']
});
allTournamentMembers.forEach(tm => validTournamentMemberIds.add(tm.id));
// Lade auch alle ExternalTournamentParticipant-IDs, um zu prüfen, ob eine ID zu einem externen Teilnehmer gehört
const validExternalParticipantIds = new Set();
const allExternalParticipants = await ExternalTournamentParticipant.findAll({
where: { tournamentId },
attributes: ['id']
});
allExternalParticipants.forEach(ep => validExternalParticipantIds.add(ep.id));
// Prüfe für jedes Match, ob die player1Id/player2Id zu einem externen Teilnehmer gehört
// Wenn ja, setze das geladene TournamentMember auf null (auch wenn es zufällig geladen wurde)
matches.forEach(match => {
if (match.player1Id && validExternalParticipantIds.has(match.player1Id)) {
// Diese ID gehört zu einem externen Teilnehmer, also sollte player1 null sein
match.player1 = null;
} else if (match.player1 && !validTournamentMemberIds.has(match.player1.id)) {
// Das geladene TournamentMember gehört nicht zu diesem Turnier
match.player1 = null;
}
if (match.player2Id && validExternalParticipantIds.has(match.player2Id)) {
// Diese ID gehört zu einem externen Teilnehmer, also sollte player2 null sein
match.player2 = null;
} else if (match.player2 && !validTournamentMemberIds.has(match.player2.id)) {
// Das geladene TournamentMember gehört nicht zu diesem Turnier
match.player2 = null;
}
});
// Lade externe Teilnehmer für Matches, bei denen player1 oder player2 null ist
const player1Ids = matches.filter(m => !m.player1 && m.player1Id).map(m => m.player1Id);
const player2Ids = matches.filter(m => !m.player2 && m.player2Id).map(m => m.player2Id);
const externalPlayerIds = [...new Set([...player1Ids, ...player2Ids])];
if (externalPlayerIds.length > 0) {
const externalPlayers = await ExternalTournamentParticipant.findAll({
where: {
id: { [Op.in]: externalPlayerIds },
tournamentId
}
});
const externalPlayerMap = new Map();
externalPlayers.forEach(ep => {
externalPlayerMap.set(ep.id, ep);
});
// Ersetze null player1/player2 mit externen Teilnehmern
// WICHTIG: Stelle sicher, dass externe Teilnehmer KEIN member-Feld haben
matches.forEach(match => {
if (!match.player1 && externalPlayerMap.has(match.player1Id)) {
const externalPlayer = externalPlayerMap.get(match.player1Id);
// Erstelle ein sauberes Objekt ohne member-Feld
match.player1 = {
id: externalPlayer.id,
firstName: externalPlayer.firstName,
lastName: externalPlayer.lastName,
club: externalPlayer.club,
gender: externalPlayer.gender,
birthDate: externalPlayer.birthDate
};
}
if (!match.player2 && externalPlayerMap.has(match.player2Id)) {
const externalPlayer = externalPlayerMap.get(match.player2Id);
// Erstelle ein sauberes Objekt ohne member-Feld
match.player2 = {
id: externalPlayer.id,
firstName: externalPlayer.firstName,
lastName: externalPlayer.lastName,
club: externalPlayer.club,
gender: externalPlayer.gender,
birthDate: externalPlayer.birthDate
};
}
});
// 2) Alle im Turnier verwendeten Spieler-IDs einsammeln
const allPlayerIds = new Set();
for (const m of matches) {
if (m.player1Id) allPlayerIds.add(m.player1Id);
if (m.player2Id) allPlayerIds.add(m.player2Id);
}
return matches;
const idsArray = Array.from(allPlayerIds);
// Wenn gar keine Spieler-IDs vorhanden sind, können wir direkt zurückgeben
if (idsArray.length === 0) {
const plain = matches.map(m => m.toJSON());
return plain;
}
// 3) Passende interne Turnierteilnehmer + Member laden
const internalMembers = await TournamentMember.findAll({
where: {
tournamentId,
id: { [Op.in]: idsArray }
},
include: [{ model: Member, as: 'member' }]
});
const internalById = new Map(internalMembers.map(m => [m.id, m]));
// 4) Passende externe Teilnehmer laden
const externalParticipants = await ExternalTournamentParticipant.findAll({
where: {
tournamentId,
id: { [Op.in]: idsArray }
}
});
const externalById = new Map(externalParticipants.map(e => [e.id, e]));
// 5) Finale, saubere Struktur erzeugen
const finalMatches = matches.map(m => {
const plain = m.toJSON();
// player1
const p1Id = plain.player1Id;
if (p1Id && internalById.has(p1Id)) {
plain.player1 = internalById.get(p1Id).toJSON();
} else if (p1Id && externalById.has(p1Id)) {
const ep = externalById.get(p1Id);
plain.player1 = {
id: ep.id,
firstName: ep.firstName,
lastName: ep.lastName,
club: ep.club,
gender: ep.gender,
birthDate: ep.birthDate
};
} else {
plain.player1 = null;
}
// player2
const p2Id = plain.player2Id;
if (p2Id && internalById.has(p2Id)) {
plain.player2 = internalById.get(p2Id).toJSON();
} else if (p2Id && externalById.has(p2Id)) {
const ep = externalById.get(p2Id);
plain.player2 = {
id: ep.id,
firstName: ep.firstName,
lastName: ep.lastName,
club: ep.club,
gender: ep.gender,
birthDate: ep.birthDate
};
} else {
plain.player2 = null;
}
return plain;
});
return finalMatches;
}
// 12. Satz-Ergebnis hinzufügen/überschreiben
@@ -2059,21 +2241,25 @@ class TournamentService {
const stats = {};
// Interne Teilnehmer
for (const tm of g.tournamentGroupMembers || []) {
stats[tm.id] = { member: tm, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: false };
stats[tm.id] = { member: tm, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: false, matchesWon: 0, matchesLost: 0 };
}
// Externe Teilnehmer
for (const ext of g.externalGroupMembers || []) {
stats[ext.id] = { member: ext, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: true };
stats[ext.id] = { member: ext, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: true, matchesWon: 0, matchesLost: 0 };
}
for (const m of groupMatches.filter(m => m.groupId === g.id)) {
if (!stats[m.player1Id] || !stats[m.player2Id]) continue;
const [p1, p2] = m.result.split(":").map(n => parseInt(n, 10));
if (p1 > p2) {
stats[m.player1Id].points += 1; // Sieger bekommt +1
stats[m.player1Id].matchesWon += 1;
stats[m.player2Id].points -= 1; // Verlierer bekommt -1
stats[m.player2Id].matchesLost += 1;
} else {
stats[m.player2Id].points += 1; // Sieger bekommt +1
stats[m.player2Id].matchesWon += 1;
stats[m.player1Id].points -= 1; // Verlierer bekommt -1
stats[m.player1Id].matchesLost += 1;
}
stats[m.player1Id].setsWon += p1;
stats[m.player1Id].setsLost += p2;
@@ -2084,8 +2270,9 @@ class TournamentService {
if (m.tournamentResults && m.tournamentResults.length > 0) {
let p1Points = 0, p2Points = 0;
for (const r of m.tournamentResults) {
p1Points += r.pointsPlayer1 || 0;
p2Points += r.pointsPlayer2 || 0;
// Verwende absolute Werte, falls negative Werte gespeichert wurden
p1Points += Math.abs(r.pointsPlayer1 || 0);
p2Points += Math.abs(r.pointsPlayer2 || 0);
}
stats[m.player1Id].pointsWon += p1Points;
stats[m.player1Id].pointsLost += p2Points;
@@ -2503,55 +2690,77 @@ class TournamentService {
throw new Error('Turnier nicht gefunden');
}
// Hole bestehende Gruppen
const existingGroups = await TournamentGroup.findAll({
where: { tournamentId },
order: [['id', 'ASC']]
});
console.log(`[assignParticipantToGroup] Found ${existingGroups.length} existing groups`);
// Berechne dbGroupId
let dbGroupId = null;
if (groupNumber != null) {
// Stelle sicher, dass genug Gruppen existieren
if (groupNumber > existingGroups.length) {
console.log(`[assignParticipantToGroup] Creating ${groupNumber - existingGroups.length} new groups`);
// Erstelle fehlende Gruppen
for (let i = existingGroups.length; i < groupNumber; i++) {
const grp = await TournamentGroup.create({ tournamentId });
existingGroups.push(grp);
}
}
// Mapping von groupNumber (1-based) zu groupId
dbGroupId = existingGroups[groupNumber - 1].id;
console.log(`[assignParticipantToGroup] Mapped groupNumber ${groupNumber} to dbGroupId ${dbGroupId}`);
}
// Aktualisiere den Teilnehmer
// Hole zunächst den Teilnehmer, um seine classId zu erhalten
let participantClassId = null;
let internalMember = null;
let externalMember = null;
if (isExternal) {
const externalMember = await ExternalTournamentParticipant.findOne({
externalMember = await ExternalTournamentParticipant.findOne({
where: { id: participantId, tournamentId }
});
if (!externalMember) {
throw new Error(`Externer Teilnehmer mit ID ${participantId} nicht gefunden`);
}
console.log(`[assignParticipantToGroup] Updating external member ${participantId} to groupId ${dbGroupId}`);
await ExternalTournamentParticipant.update(
{ groupId: dbGroupId },
{ where: { id: participantId, tournamentId } }
);
participantClassId = externalMember.classId;
} else {
const internalMember = await TournamentMember.findOne({
internalMember = await TournamentMember.findOne({
where: { id: participantId, tournamentId }
});
if (!internalMember) {
throw new Error(`Interner Teilnehmer mit ID ${participantId} nicht gefunden`);
}
console.log(`[assignParticipantToGroup] Updating internal member ${participantId} (memberId=${internalMember.memberId}) to groupId ${dbGroupId}`);
participantClassId = internalMember.classId;
}
// Hole bestehende Gruppen für diese Klasse (oder ohne Klasse, wenn classId null)
const whereClause = { tournamentId };
if (participantClassId === null) {
whereClause.classId = { [Op.is]: null };
} else {
whereClause.classId = participantClassId;
}
const existingGroups = await TournamentGroup.findAll({
where: whereClause,
order: [['id', 'ASC']]
});
console.log(`[assignParticipantToGroup] Found ${existingGroups.length} existing groups for classId ${participantClassId}`);
// Berechne dbGroupId
let dbGroupId = null;
if (groupNumber != null && groupNumber > 0) {
// Stelle sicher, dass genug Gruppen existieren
if (groupNumber > existingGroups.length) {
console.log(`[assignParticipantToGroup] Creating ${groupNumber - existingGroups.length} new groups for classId ${participantClassId}`);
// Erstelle fehlende Gruppen
for (let i = existingGroups.length; i < groupNumber; i++) {
const grp = await TournamentGroup.create({
tournamentId,
classId: participantClassId
});
existingGroups.push(grp);
}
}
// Mapping von groupNumber (1-based) zu groupId innerhalb dieser Klasse
if (existingGroups.length >= groupNumber) {
dbGroupId = existingGroups[groupNumber - 1].id;
console.log(`[assignParticipantToGroup] Mapped groupNumber ${groupNumber} to dbGroupId ${dbGroupId} for classId ${participantClassId}`);
} else {
throw new Error(`Ungültiges groupNumber ${groupNumber} - es gibt nur ${existingGroups.length} Gruppen für classId ${participantClassId}`);
}
}
// Aktualisiere den Teilnehmer
if (isExternal) {
console.log(`[assignParticipantToGroup] Updating external member ${participantId} (classId=${participantClassId}) to groupId ${dbGroupId}`);
await ExternalTournamentParticipant.update(
{ groupId: dbGroupId },
{ where: { id: participantId, tournamentId } }
);
} else {
console.log(`[assignParticipantToGroup] Updating internal member ${participantId} (memberId=${internalMember.clubMemberId || 'N/A'}, classId=${participantClassId}) to groupId ${dbGroupId}`);
await TournamentMember.update(
{ groupId: dbGroupId },
{ where: { id: participantId, tournamentId } }
@@ -2570,9 +2779,16 @@ class TournamentService {
await TournamentMatch.destroy({ where: { tournamentId } });
await TournamentGroup.destroy({ where: { tournamentId } });
}
async resetMatches(userToken, clubId, tournamentId) {
async resetMatches(userToken, clubId, tournamentId, classId = null) {
await checkAccess(userToken, clubId);
await TournamentMatch.destroy({ where: { tournamentId } });
const where = {
tournamentId,
round: 'group'
};
if (classId != null) {
where.classId = Number(classId);
}
await TournamentMatch.destroy({ where });
}
async removeParticipant(userToken, clubId, tournamentId, participantId) {

View File

@@ -90,13 +90,16 @@
<td><strong>G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}</strong></td>
<td>{{ pl.position }}.</td>
<td><span v-if="pl.seeded" class="seeded-star"></span>{{ pl.name }}</td>
<td>{{ pl.points }}</td>
<td>{{ (pl.matchesWon || 0) * 2 }}:{{ (pl.matchesLost || 0) * 2 }}</td>
<td>{{ pl.setsWon }}:{{ pl.setsLost }}</td>
<td>
{{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}
</td>
<td>
{{ pl.pointsWon }}:{{ pl.pointsLost }} ({{ (pl.pointsWon - pl.pointsLost) >= 0 ? '+' + (pl.pointsWon - pl.pointsLost) : (pl.pointsWon - pl.pointsLost) }})
{{ Math.abs(pl.pointsWon || 0) }}:{{ Math.abs(pl.pointsLost || 0) }}
<span v-if="(Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0)) !== 0" class="points-diff">
({{ (Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0)) >= 0 ? '+' : '' }}{{ Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0) }})
</span>
</td>
<td v-for="(opponent, oppIdx) in groupRankings[group.groupId]"
:key="`match-${pl.id}-${opponent.id}`"
@@ -116,12 +119,12 @@
</div>
</template>
</template>
<div v-if="!matches.some(m => m.round === 'group')" class="reset-controls" style="margin-top:1rem">
<div v-if="filteredGroupMatches.length === 0" class="reset-controls" style="margin-top:1rem">
<button @click="$emit('create-matches')" class="btn-primary">
Gruppenspiele berechnen
</button>
</div>
<div v-if="matches.some(m => m.round === 'group')" class="reset-controls" style="margin-top:1rem">
<div v-if="filteredGroupMatches.length > 0" class="reset-controls" style="margin-top:1rem">
<button @click="$emit('reset-matches')" class="trash-btn">
🗑 {{ $t('tournaments.resetGroupMatches') }}
</button>
@@ -207,7 +210,34 @@ export default {
'create-matches',
'highlight-match'
],
computed: {
filteredGroupMatches() {
return this.filterMatchesByClass(this.matches.filter(m => m.round === 'group'));
}
},
methods: {
filterMatchesByClass(matches) {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
return matches;
}
// Wenn "Ohne Klasse" ausgewählt ist
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
return matches.filter(m => m.classId === null || m.classId === undefined);
}
// Filtere nach der ausgewählten Klasse
const selectedId = Number(this.selectedViewClass);
if (Number.isNaN(selectedId)) {
return matches;
}
return matches.filter(m => {
const matchClassId = m.classId;
if (matchClassId === null || matchClassId === undefined) {
return false;
}
return Number(matchClassId) === selectedId;
});
},
shouldShowClass(classId) {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {

View File

@@ -7,7 +7,7 @@
:selected-date="selectedDate"
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
<section v-if="groupMatches.length" class="group-matches">
<section v-if="filteredGroupMatches.length" class="group-matches">
<h4>{{ $t('tournaments.groupMatches') }}</h4>
<table>
<thead>
@@ -21,7 +21,7 @@
</tr>
</thead>
<tbody>
<tr v-for="m in groupMatches" :key="m.id" :data-match-id="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
<tr v-for="m in filteredGroupMatches" :key="m.id" :data-match-id="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
<td>{{ m.groupRound }}</td>
<td>
<template v-if="getGroupClassName(m.groupId)">
@@ -95,22 +95,22 @@
</tbody>
</table>
</section>
<div v-if="participants.length > 1 && !groupMatches.length && !knockoutMatches.length" class="start-matches" style="margin-top:1.5rem">
<div v-if="participants.length > 1 && !filteredGroupMatches.length && !filteredKnockoutMatches.length" class="start-matches" style="margin-top:1.5rem">
<button @click="$emit('start-matches')">
{{ $t('tournaments.createMatches') }}
</button>
</div>
<div v-if="canStartKnockout && !showKnockout && getTotalNumberOfGroups > 1" class="ko-start">
<div v-if="canStartKnockout && !showKnockout && numberOfGroupsForSelectedClass > 1" class="ko-start">
<button @click="$emit('start-knockout')">
{{ $t('tournaments.startKORound') }}
</button>
</div>
<div v-if="showKnockout && canResetKnockout && getTotalNumberOfGroups > 1" class="ko-reset" style="margin-top:1rem">
<div v-if="showKnockout && canResetKnockout && numberOfGroupsForSelectedClass > 1" class="ko-reset" style="margin-top:1rem">
<button @click="$emit('reset-knockout')" class="trash-btn">
🗑 {{ $t('tournaments.deleteKORound') }}
</button>
</div>
<section v-if="showKnockout && getTotalNumberOfGroups > 1" class="ko-round">
<section v-if="showKnockout && numberOfGroupsForSelectedClass > 1 && filteredKnockoutMatches.length" class="ko-round">
<h4>{{ $t('tournaments.koRound') }}</h4>
<table>
<thead>
@@ -124,7 +124,7 @@
</tr>
</thead>
<tbody>
<tr v-for="m in knockoutMatches" :key="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
<tr v-for="m in filteredKnockoutMatches" :key="m.id" :class="{ 'active-match': activeMatchId === m.id, 'match-finished': m.isFinished, 'match-live': m.isActive }" @click="$emit('update:activeMatchId', m.id)">
<td>{{ getKnockoutMatchClassName(m) }}</td>
<td>{{ m.round }}</td>
<td>
@@ -188,9 +188,9 @@
</tbody>
</table>
</section>
<section v-if="Object.keys(groupedRankingList).length > 0" class="ranking">
<section v-if="Object.keys(filteredGroupedRankingList).length > 0" class="ranking">
<h4>Rangliste</h4>
<template v-for="(classKey, idx) in Object.keys(groupedRankingList).sort((a, b) => {
<template v-for="(classKey, idx) in Object.keys(filteredGroupedRankingList).sort((a, b) => {
const aNum = a === 'null' ? 999999 : parseInt(a);
const bNum = b === 'null' ? 999999 : parseInt(b);
return aNum - bNum;
@@ -205,7 +205,7 @@
</tr>
</thead>
<tbody>
<tr v-for="(entry, entryIdx) in groupedRankingList[classKey].filter(e => Number(e.position) <= 3)" :key="`${entry.member.id}-${entryIdx}`">
<tr v-for="(entry, entryIdx) in filteredGroupedRankingList[classKey].filter(e => Number(e.position) <= 3)" :key="`${entry.member.id}-${entryIdx}`">
<td>{{ entry.position }}.</td>
<td>
{{ entry.member.firstName }}
@@ -289,6 +289,71 @@ export default {
required: true
}
},
computed: {
filteredGroupMatches() {
return this.filterMatchesByClass(this.groupMatches);
},
filteredKnockoutMatches() {
return this.filterMatchesByClass(this.knockoutMatches);
},
filteredGroupedRankingList() {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
return this.groupedRankingList;
}
// Wenn "Ohne Klasse" ausgewählt ist
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
const result = {};
if (this.groupedRankingList['null']) {
result['null'] = this.groupedRankingList['null'];
}
return result;
}
// Filtere nach der ausgewählten Klasse
const selectedId = Number(this.selectedViewClass);
if (Number.isNaN(selectedId)) {
return this.groupedRankingList;
}
const result = {};
const classKey = String(selectedId);
if (this.groupedRankingList[classKey]) {
result[classKey] = this.groupedRankingList[classKey];
}
return result;
},
numberOfGroupsForSelectedClass() {
// Zähle direkt die Gruppen für die ausgewählte Klasse
// Nur Stage 1 Gruppen (stageId null/undefined) zählen
// Und nur Gruppen mit mindestens einem Teilnehmer
let groupsToCount = this.groups.filter(g =>
(!g.stageId || g.stageId === null || g.stageId === undefined) &&
g.participants && Array.isArray(g.participants) && g.participants.length > 0
);
// Wenn keine Klasse ausgewählt ist, zähle alle Stage 1 Gruppen
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
return groupsToCount.length;
}
// Wenn "Ohne Klasse" ausgewählt ist
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
return groupsToCount.filter(g => g.classId === null || g.classId === undefined).length;
}
// Filtere nach der ausgewählten Klasse
const selectedId = Number(this.selectedViewClass);
if (Number.isNaN(selectedId)) {
return groupsToCount.length;
}
return groupsToCount.filter(g => {
if (g.classId === null || g.classId === undefined) {
return false;
}
return Number(g.classId) === selectedId;
}).length;
}
},
emits: [
'update:selectedViewClass',
'update:activeMatchId',
@@ -305,6 +370,28 @@ export default {
'reset-knockout'
],
methods: {
filterMatchesByClass(matches) {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
if (this.selectedViewClass === null || this.selectedViewClass === undefined) {
return matches;
}
// Wenn "Ohne Klasse" ausgewählt ist
if (this.selectedViewClass === '__none__' || this.selectedViewClass === 'null') {
return matches.filter(m => m.classId === null || m.classId === undefined);
}
// Filtere nach der ausgewählten Klasse
const selectedId = Number(this.selectedViewClass);
if (Number.isNaN(selectedId)) {
return matches;
}
return matches.filter(m => {
const matchClassId = m.classId;
if (matchClassId === null || matchClassId === undefined) {
return false;
}
return Number(matchClassId) === selectedId;
});
},
getGroupClassName(groupId) {
if (!groupId) return '';
const group = this.groups.find(g => g.groupId === groupId);

View File

@@ -175,7 +175,7 @@
@randomize-groups="randomizeGroups()"
@reset-groups="resetGroups()"
@reset-matches="resetMatches()"
@create-matches="startMatches()"
@create-matches="createMatches()"
@highlight-match="highlightMatch"
/>
@@ -487,9 +487,11 @@ export default {
setsWon: p.setsWon || 0,
setsLost: p.setsLost || 0,
setDiff: p.setDiff || 0,
pointsWon: p.pointsWon || 0,
pointsLost: p.pointsLost || 0,
pointRatio: p.pointRatio || 0
pointsWon: Math.abs(p.pointsWon || 0),
pointsLost: Math.abs(p.pointsLost || 0),
pointRatio: p.pointRatio || 0,
matchesWon: p.matchesWon || 0,
matchesLost: p.matchesLost || 0
}));
});
return rankings;
@@ -1314,6 +1316,41 @@ export default {
this.showKnockout = this.matches.some(m => m.round !== 'group');
},
async loadMatches() {
// Lade nur die Matches, ohne die Teilnehmer-Daten zu überschreiben
const mRes = await apiClient.get(
`/tournament/matches/${this.currentClub}/${this.selectedDate}`
);
const grpMap = this.groups.reduce((m, g) => {
m[g.groupId] = g.groupNumber;
return m;
}, {});
this.matches = mRes.data.map(m => {
// Verwende groupId aus dem Backend, falls vorhanden, sonst aus den Spielern
const matchGroupId = m.groupId || m.player1?.groupId || m.player2?.groupId;
// Stelle sicher, dass groupRound vorhanden ist (kann als group_round vom Backend kommen)
const groupRound = m.groupRound || m.group_round || 0;
const groupNumber = grpMap[matchGroupId] || 0;
return {
...m,
groupId: matchGroupId,
groupNumber: groupNumber,
groupRound: groupRound,
resultInput: '',
isActive: m.isActive || false
};
});
// Setze Kollaps-Status: ausgeklappt wenn keine Spiele, eingeklappt wenn Spiele vorhanden
this.showParticipants = this.matches.length === 0;
this.showKnockout = this.matches.some(m => m.round !== 'group');
},
async handleTournamentChanged(data) {
if (!data || !data.tournamentId) {
@@ -1855,6 +1892,25 @@ export default {
await this.loadTournamentData();
},
async createMatches() {
if (!this.isGroupTournament) {
return;
}
if (!this.groups.length) {
await this.createGroups();
}
// Übergebe classId, wenn eine Klasse ausgewählt ist (nicht '__none__' oder null)
const classId = (this.selectedViewClass && this.selectedViewClass !== '__none__' && this.selectedViewClass !== null)
? this.selectedViewClass
: null;
await apiClient.post('/tournament/matches/create', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
classId: classId
});
await this.loadTournamentData();
},
async onModusChange() {
const type = this.isGroupTournament ? 'groups' : 'knockout';
const desired = Math.max(1, parseInt(String(this.numberOfGroups), 10) || 1);
@@ -1877,9 +1933,14 @@ export default {
},
async resetMatches() {
// Übergebe classId, wenn eine Klasse ausgewählt ist (nicht '__none__' oder null)
const classId = (this.selectedViewClass && this.selectedViewClass !== '__none__' && this.selectedViewClass !== null)
? this.selectedViewClass
: null;
await apiClient.post('/tournament/matches/reset', {
clubId: this.currentClub,
tournamentId: this.selectedDate
tournamentId: this.selectedDate,
classId: classId
});
await this.loadTournamentData();
},
@@ -2201,8 +2262,17 @@ export default {
console.log('[updateParticipantGroup] Updating participant:', participant.id, 'to groupNumber:', groupNumber, 'isExternal:', participant.isExternal);
// Aktualisiere lokal
participant.groupNumber = groupNumber;
// Speichere die alte groupNumber für den Fall eines Fehlers
const oldGroupNumber = participant.groupNumber;
// Aktualisiere lokal sofort für responsive UI (mit Vue-Reaktivität)
if (this.$set) {
// Vue 2
this.$set(participant, 'groupNumber', groupNumber);
} else {
// Vue 3
participant.groupNumber = groupNumber;
}
// Sende nur diesen einen Teilnehmer an Backend
try {
@@ -2221,17 +2291,86 @@ export default {
this.groups = [...response.data];
console.log('[updateParticipantGroup] Updated groups:', this.groups);
} else {
// Fallback: Lade Daten neu
await this.loadTournamentData();
// Fallback: Lade Gruppen neu
const gRes = await apiClient.get('/tournament/groups', {
params: {
clubId: this.currentClub,
tournamentId: this.selectedDate
}
});
const groupsData = Array.isArray(gRes.data)
? gRes.data
: (Array.isArray(gRes.data?.groups) ? gRes.data.groups : []);
this.groups = [...groupsData];
}
// Force Vue update, um sicherzustellen, dass die Gruppenübersicht aktualisiert wird
this.$forceUpdate();
// Aktualisiere auch die groupId des Teilnehmers basierend auf der neuen groupNumber
if (groupNumber !== null) {
const newGroup = this.groups.find(g => g.groupNumber === groupNumber);
if (newGroup) {
if (this.$set) {
this.$set(participant, 'groupId', newGroup.groupId);
} else {
participant.groupId = newGroup.groupId;
}
} else {
// Gruppe nicht gefunden, setze groupId auf null
if (this.$set) {
this.$set(participant, 'groupId', null);
} else {
participant.groupId = null;
}
}
} else {
// groupNumber ist null, also auch groupId auf null
if (this.$set) {
this.$set(participant, 'groupId', null);
} else {
participant.groupId = null;
}
}
// Lade Matches neu, da sich die Gruppenzuordnung geändert hat
await this.loadMatches();
} catch (error) {
console.error('Fehler beim Aktualisieren der Gruppe:', error);
// Bei Fehler: Lade Daten neu und setze groupNumber zurück
participant.groupNumber = participant.groupId ? this.groups.find(g => g.groupId === participant.groupId)?.groupNumber || null : null;
await this.loadTournamentData();
this.$forceUpdate();
// Bei Fehler: Setze groupNumber zurück auf den alten Wert
if (this.$set) {
this.$set(participant, 'groupNumber', oldGroupNumber);
} else {
participant.groupNumber = oldGroupNumber;
}
// Lade Gruppen neu, um sicherzustellen, dass groupId korrekt ist
try {
const gRes = await apiClient.get('/tournament/groups', {
params: {
clubId: this.currentClub,
tournamentId: this.selectedDate
}
});
const groupsData = Array.isArray(gRes.data)
? gRes.data
: (Array.isArray(gRes.data?.groups) ? gRes.data.groups : []);
this.groups = [...groupsData];
// Setze groupId basierend auf dem alten groupNumber
if (oldGroupNumber !== null) {
const oldGroup = this.groups.find(g => g.groupNumber === oldGroupNumber);
const oldGroupId = oldGroup ? oldGroup.groupId : null;
if (this.$set) {
this.$set(participant, 'groupId', oldGroupId);
} else {
participant.groupId = oldGroupId;
}
} else {
if (this.$set) {
this.$set(participant, 'groupId', null);
} else {
participant.groupId = null;
}
}
} catch (loadError) {
console.error('Fehler beim Neuladen der Gruppen:', loadError);
}
}
},