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 }}