diff --git a/.gitignore b/.gitignore
index a4d801f..2607a29 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,5 @@ backend/.env
backend/images/*
backend/backend-debug.log
-backend/*.log
\ No newline at end of file
+backend/*.log
+backend/.env.local
diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js
index e876c7b..669e764 100644
--- a/backend/controllers/tournamentController.js
+++ b/backend/controllers/tournamentController.js
@@ -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);
diff --git a/backend/routes/tournamentRoutes.js b/backend/routes/tournamentRoutes.js
index a6e98f8..f1a409a 100644
--- a/backend/routes/tournamentRoutes.js
+++ b/backend/routes/tournamentRoutes.js
@@ -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);
diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js
index e828850..45810f8 100644
--- a/backend/services/tournamentService.js
+++ b/backend/services/tournamentService.js
@@ -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,
});
}
- // BYE‑Matches zuerst
- let remaining = [...uniqueEntrants];
+
+ // BYE‑Matches 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) {
diff --git a/frontend/src/components/tournament/TournamentGroupsTab.vue b/frontend/src/components/tournament/TournamentGroupsTab.vue
index a9f2e45..c521b10 100644
--- a/frontend/src/components/tournament/TournamentGroupsTab.vue
+++ b/frontend/src/components/tournament/TournamentGroupsTab.vue
@@ -90,13 +90,16 @@
G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }} |
{{ pl.position }}. |
★{{ pl.name }} |
- {{ pl.points }} |
+ {{ (pl.matchesWon || 0) * 2 }}:{{ (pl.matchesLost || 0) * 2 }} |
{{ pl.setsWon }}:{{ pl.setsLost }} |
{{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}
|
- {{ 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) }}
+
+ ({{ (Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0)) >= 0 ? '+' : '' }}{{ Math.abs(pl.pointsWon || 0) - Math.abs(pl.pointsLost || 0) }})
+
|
-
+
-
+
@@ -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) {
diff --git a/frontend/src/components/tournament/TournamentResultsTab.vue b/frontend/src/components/tournament/TournamentResultsTab.vue
index 4955240..224a524 100644
--- a/frontend/src/components/tournament/TournamentResultsTab.vue
+++ b/frontend/src/components/tournament/TournamentResultsTab.vue
@@ -7,7 +7,7 @@
:selected-date="selectedDate"
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
-
+
{{ $t('tournaments.groupMatches') }}
@@ -21,7 +21,7 @@
-
+
| {{ m.groupRound }} |
@@ -95,22 +95,22 @@
|
-
+
-
+
-
+
-
+
{{ $t('tournaments.koRound') }}
@@ -124,7 +124,7 @@
-
+
| {{ getKnockoutMatchClassName(m) }} |
{{ m.round }} |
@@ -188,9 +188,9 @@
|
-
+
Rangliste
- {
const aNum = a === 'null' ? 999999 : parseInt(a);
const bNum = b === 'null' ? 999999 : parseInt(b);
return aNum - bNum;
@@ -205,7 +205,7 @@
-
+
| {{ entry.position }}. |
{{ 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);
diff --git a/frontend/src/views/TournamentTab.vue b/frontend/src/views/TournamentTab.vue
index ac21e78..d213e7d 100644
--- a/frontend/src/views/TournamentTab.vue
+++ b/frontend/src/views/TournamentTab.vue
@@ -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);
+ }
}
},
| |