Implement external participant management and tournament class features

This commit enhances the tournament management system by introducing functionality for handling external participants and tournament classes. New methods are added to the `tournamentController` and `tournamentService` for adding, retrieving, updating, and removing external participants, as well as managing tournament classes. The backend models are updated to support these features, including new relationships and attributes. The frontend is also updated to allow users to manage external participants and classes, improving the overall user experience and interactivity in tournament management.
This commit is contained in:
Torsten Schulz (local)
2025-11-14 22:36:51 +01:00
parent 3334d76688
commit d08835e206
24 changed files with 2798 additions and 326 deletions

View File

@@ -95,9 +95,13 @@
<span class="nav-icon">🏆</span>
Interne Turniere
</router-link>
<router-link v-if="hasPermission('tournaments', 'read')" to="/official-tournaments" class="nav-link" title="Offizielle Turniere">
<router-link v-if="hasPermission('tournaments', 'read')" to="/external-tournaments" class="nav-link" title="Turniere mit Externen">
<span class="nav-icon">🌐</span>
Offene Turniere
</router-link>
<router-link v-if="hasPermission('tournaments', 'read')" to="/official-tournaments" class="nav-link" title="Turnierteilnahmen">
<span class="nav-icon">📄</span>
Offizielle Turniere
Turnierteilnahmen
</router-link>
<router-link v-if="hasPermission('predefined_activities', 'read')" to="/predefined-activities" class="nav-link" title="Vordefinierte Aktivitäten">
<span class="nav-icon"></span>

View File

@@ -58,6 +58,7 @@ class PDFGenerator {
this.pdf.addPage();
this.xPos = this.margin;
this.yPos = this.position;
this.cursorY = this.margin;
this.isLeftColumn = true;
}
@@ -606,6 +607,552 @@ class PDFGenerator {
this.cursorY = y + 10;
}
addTournamentPDF(tournamentName, tournamentDate, groupsByClass, groupRankings, matchesByClassAndGroup, getPlayerName, knockoutRanking, participants, hasKnockoutMatches, knockoutMatches) {
// Header
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(16);
this.pdf.text(tournamentName || 'Turnier', this.margin, this.cursorY);
this.cursorY += 8;
if (tournamentDate) {
this.pdf.setFont('helvetica', 'normal');
this.pdf.setFontSize(12);
const formattedDate = new Date(tournamentDate).toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
this.pdf.text(`Datum: ${formattedDate}`, this.margin, this.cursorY);
this.cursorY += 10;
}
// 1. Gesamt-Ranking nach Klassen (nur Platz und Spieler)
// knockoutRanking und participants werden als zusätzliche Parameter übergeben
// Verwende K.O.-Ranking nur wenn es vorhanden ist UND K.O.-Runden existieren
const useKnockoutRanking = knockoutRanking && knockoutRanking.length > 0 && hasKnockoutMatches;
this.addTournamentClassRankings(groupsByClass, groupRankings, getPlayerName, useKnockoutRanking ? knockoutRanking : null, participants);
// 2. Gruppen-Matrizen mit Ergebnissen (neue Seite)
this.addTournamentGroupMatrices(groupsByClass, groupRankings, getPlayerName);
// 4. Alle Spiele nach Klasse und Gruppe sortiert (inkl. K.O.-Runden)
this.addTournamentMatches(matchesByClassAndGroup, getPlayerName, knockoutMatches);
}
addTournamentClassRankings(groupsByClass, groupRankings, getPlayerName, knockoutRanking, participants) {
// Wenn K.O.-Runden vorhanden sind, verwende diese für das Ranking
if (knockoutRanking && knockoutRanking.length > 0) {
// Erstelle Mapping von Member-ID zu classId
// Für interne Teilnehmer: member.id -> classId
const memberClassMap = {};
if (participants) {
participants.forEach(p => {
// Interne Teilnehmer haben ein member-Objekt
if (p.member && p.member.id) {
memberClassMap[p.member.id] = p.classId;
}
});
}
// Gruppiere K.O.-Ranking nach Klassen
// entry.classId sollte bereits vorhanden sein (aus extendedRankingList)
const rankingByClass = {};
knockoutRanking.forEach(entry => {
// Verwende classId direkt aus entry, falls vorhanden
let classId = entry.classId;
// Fallback: Suche über memberId, falls classId nicht vorhanden
if (classId == null && entry.member) {
const memberId = entry.member.id;
if (memberId && participants) {
// Suche im Mapping
classId = memberClassMap[memberId] || null;
// Falls nicht gefunden, suche direkt in participants
if (!classId) {
const participant = participants.find(p =>
p.member && p.member.id === memberId
);
if (participant) {
classId = participant.classId;
}
}
}
}
const classKey = classId != null ? String(classId) : 'null';
if (!rankingByClass[classKey]) {
rankingByClass[classKey] = [];
}
const playerName = entry.member
? `${entry.member.firstName || ''} ${entry.member.lastName || ''}`.trim()
: getPlayerName(entry.player || entry);
rankingByClass[classKey].push({
position: entry.position,
name: playerName
});
});
// Sortiere innerhalb jeder Klasse nach Position
Object.keys(rankingByClass).forEach(classKey => {
rankingByClass[classKey].sort((a, b) => a.position - b.position);
});
// Zeige Rankings nach Klassen (sortiert nach classId)
Object.entries(rankingByClass).sort((a, b) => {
const aNum = a[0] === 'null' ? 999999 : parseInt(a[0]);
const bNum = b[0] === 'null' ? 999999 : parseInt(b[0]);
return aNum - bNum;
}).forEach(([classId, players]) => {
const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null;
// Prüfe ob neue Seite nötig
if (this.cursorY > 250) {
this.addNewPage();
this.cursorY = this.margin;
}
// Klassen-Überschrift
if (className) {
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text(className, this.margin, this.cursorY);
this.cursorY += 10;
}
// Zeige Spieler
players.forEach(player => {
if (this.cursorY > 280) {
this.addNewPage();
this.cursorY = this.margin;
// Klassen-Überschrift erneut anzeigen bei Seitenwechsel
if (className) {
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text(className, this.margin, this.cursorY);
this.cursorY += 10;
}
}
this.pdf.setFont('helvetica', 'normal');
this.pdf.setFontSize(11);
const playerText = `${player.position}. ${player.name}`;
this.pdf.text(playerText, this.margin, this.cursorY);
this.cursorY += 7;
});
// Abstand nach Klasse
this.cursorY += 5;
});
} else {
// Fallback: Verwende Gruppen-Rankings (nur wenn keine K.O.-Runden)
// Aber: Wenn es K.O.-Runden gibt, aber rankingList leer ist, bedeutet das,
// dass die K.O.-Runden noch nicht abgeschlossen sind
// In diesem Fall sollten wir trotzdem die Gruppen-Rankings verwenden
Object.entries(groupsByClass).forEach(([classId, groups]) => {
const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null;
// Prüfe ob neue Seite nötig
if (this.cursorY > 250) {
this.addNewPage();
this.cursorY = this.margin;
}
// Klassen-Überschrift
if (className) {
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text(className, this.margin, this.cursorY);
this.cursorY += 10;
}
// Sammle alle Spieler aus allen Gruppen dieser Klasse
// WICHTIG: Zeige alle Spieler, nicht nur die ersten Plätze
const allPlayers = [];
groups.forEach(group => {
const rankings = groupRankings[group.groupId] || [];
rankings.forEach(player => {
allPlayers.push({
position: player.position,
name: player.name,
seeded: player.seeded
});
});
});
// Sortiere nach Position (1, 1, 2, 2, 3, 3, etc.)
allPlayers.sort((a, b) => {
if (a.position !== b.position) {
return a.position - b.position;
}
// Bei gleicher Position alphabetisch sortieren
return a.name.localeCompare(b.name);
});
// Erstelle einfache Liste: nur Platz und Spieler
allPlayers.forEach(player => {
if (this.cursorY > 280) {
this.addNewPage();
this.cursorY = this.margin;
// Klassen-Überschrift erneut anzeigen bei Seitenwechsel
if (className) {
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text(className, this.margin, this.cursorY);
this.cursorY += 10;
}
}
this.pdf.setFont('helvetica', 'normal');
this.pdf.setFontSize(11);
const playerText = `${player.position}. ${player.seeded ? '★ ' : ''}${player.name}`;
this.pdf.text(playerText, this.margin, this.cursorY);
this.cursorY += 7;
});
// Abstand nach Klasse
this.cursorY += 5;
});
}
}
addTournamentTables(groupsByClass, groupRankings, getPlayerName) {
// Für jede Klasse
Object.entries(groupsByClass).forEach(([classId, groups]) => {
const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null;
// Für jede Gruppe
groups.forEach(group => {
if (this.cursorY > 250) {
this.addNewPage();
this.cursorY = this.margin;
}
// Überschrift
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(12);
let title = `Gruppe ${group.groupNumber}`;
if (className) {
title = `${className} - ${title}`;
}
this.pdf.text(title, this.margin, this.cursorY);
this.cursorY += 8;
// Tabelle mit Rankings
const rankings = groupRankings[group.groupId] || [];
if (rankings.length > 0) {
const head = [['Platz', 'Spieler', 'Punkte', 'Sätze', 'Diff']];
const body = rankings.map(p => [
`${p.position}.`,
(p.seeded ? '★ ' : '') + p.name,
p.points.toString(),
`${p.setsWon}:${p.setsLost}`,
(p.setDiff >= 0 ? '+' : '') + p.setDiff.toString()
]);
autoTable(this.pdf, {
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
head,
body,
theme: 'grid',
styles: { fontSize: 10 },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
this.cursorY += 5;
}
});
});
}
addTournamentKnockoutRanking(knockoutRanking, getPlayerName) {
// Prüfe ob neue Seite nötig
if (this.cursorY > 200) {
this.addNewPage();
this.cursorY = this.margin;
}
// Überschrift
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text('Gesamt-Ranking (K.O.-Runden)', this.margin, this.cursorY);
this.cursorY += 10;
// Ranking-Tabelle
const head = [['Platz', 'Spieler']];
const body = knockoutRanking.map(entry => {
const playerName = entry.member
? `${entry.member.firstName || ''} ${entry.member.lastName || ''}`.trim()
: getPlayerName(entry.player || entry);
return [
`${entry.position}.`,
playerName
];
});
autoTable(this.pdf, {
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
head,
body,
theme: 'grid',
styles: { fontSize: 11 },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
this.cursorY += 5;
}
addTournamentGroupMatrices(groupsByClass, groupRankings, getPlayerName) {
// Neue Seite für Matrizen
this.addNewPage();
this.cursorY = this.margin;
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text('Gruppen-Matrizen', this.margin, this.cursorY);
this.cursorY += 10;
// Für jede Klasse
Object.entries(groupsByClass).forEach(([classId, groups]) => {
const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null;
// Für jede Gruppe
groups.forEach(group => {
if (this.cursorY > 200) {
this.addNewPage();
this.cursorY = this.margin;
}
const rankings = groupRankings[group.groupId] || [];
if (rankings.length === 0) return;
// Überschrift
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(11);
let title = `Gruppe ${group.groupNumber}`;
if (className) {
title = `${className} - ${title}`;
}
this.pdf.text(title, this.margin, this.cursorY);
this.cursorY += 7;
// Matrix erstellen
const head = [['', ...rankings.map((p, idx) => `G${String.fromCharCode(96 + group.groupNumber)}${idx + 1}`)]];
const body = rankings.map((player, idx) => {
const row = [`G${String.fromCharCode(96 + group.groupNumber)}${idx + 1} ${(player.seeded ? '★ ' : '') + player.name}`];
rankings.forEach((opponent, oppIdx) => {
if (idx === oppIdx) {
row.push('-');
} else {
// Finde Match-Ergebnis
const match = this.findMatchResult ? this.findMatchResult(player.id, opponent.id, group.groupId) : null;
row.push(match || '-');
}
});
return row;
});
autoTable(this.pdf, {
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
head,
body,
theme: 'grid',
styles: { fontSize: 8 },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
columnStyles: {
0: { cellWidth: 50 }
},
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
this.cursorY += 5;
});
});
}
addTournamentMatches(matchesByClassAndGroup, getPlayerName, knockoutMatches = []) {
// Neue Seite für Spiele
this.addNewPage();
this.cursorY = this.margin;
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text('Alle Spiele', this.margin, this.cursorY);
this.cursorY += 10;
// Für jede Klasse
Object.entries(matchesByClassAndGroup).forEach(([classId, groups]) => {
const className = classId !== 'null' && classId !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classId) : null;
// Für jede Gruppe
Object.entries(groups).forEach(([groupId, matches]) => {
if (matches.length === 0) return;
if (this.cursorY > 200) {
this.addNewPage();
this.cursorY = this.margin;
}
// Überschrift
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(11);
let title = `Gruppe ${matches[0].groupNumber || groupId}`;
if (className) {
title = `${className} - ${title}`;
}
this.pdf.text(title, this.margin, this.cursorY);
this.cursorY += 7;
// Spiele-Tabelle
const head = [['Runde', 'Spieler 1', 'Spieler 2', 'Ergebnis', 'Sätze']];
const body = matches.map(m => [
m.groupRound?.toString() || '-',
getPlayerName(m.player1),
getPlayerName(m.player2),
m.isFinished ? (m.result || '-') : '-',
this.formatSets(m)
]);
autoTable(this.pdf, {
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
head,
body,
theme: 'grid',
styles: { fontSize: 9 },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
this.cursorY += 5;
});
});
// K.O.-Runden hinzufügen (wenn vorhanden)
if (knockoutMatches && knockoutMatches.length > 0) {
// Gruppiere K.O.-Matches nach Klassen
const knockoutMatchesByClass = {};
knockoutMatches.forEach(match => {
const classKey = match.classId != null ? String(match.classId) : 'null';
if (!knockoutMatchesByClass[classKey]) {
knockoutMatchesByClass[classKey] = [];
}
knockoutMatchesByClass[classKey].push(match);
});
// Sortiere Klassen
const sortedClasses = Object.keys(knockoutMatchesByClass).sort((a, b) => {
const aNum = a === 'null' ? 999999 : parseInt(a);
const bNum = b === 'null' ? 999999 : parseInt(b);
return aNum - bNum;
});
// Für jede Klasse
sortedClasses.forEach(classKey => {
const classMatches = knockoutMatchesByClass[classKey];
const className = classKey !== 'null' && classKey !== 'undefined' && this.getClassNameForId ? this.getClassNameForId(classKey) : null;
// Sortiere Matches nach Runde (frühere Runden zuerst: Achtelfinale, Viertelfinale, Halbfinale, Finale)
const getRoundType = (roundName) => {
if (!roundName) return 999;
if (roundName.includes('Achtelfinale')) return 0;
if (roundName.includes('Viertelfinale')) return 1;
if (roundName.includes('Halbfinale')) return 2;
if (roundName.includes('Finale')) return 3;
// Für Runden wie "6-Runde", "8-Runde" etc. - extrahiere die Zahl
const numberMatch = roundName.match(/(\d+)-Runde/);
if (numberMatch) {
const num = parseInt(numberMatch[1]);
// Größere Zahlen = frühere Runden, also umgekehrt sortieren
return -num; // Negativ, damit größere Zahlen zuerst kommen
}
return 999; // Unbekannte Runden zuletzt
};
classMatches.sort((a, b) => {
const aRoundType = getRoundType(a.round);
const bRoundType = getRoundType(b.round);
if (aRoundType !== bRoundType) {
return aRoundType - bRoundType;
}
// Wenn gleicher Typ, alphabetisch sortieren
return (a.round || '').localeCompare(b.round || '');
});
if (this.cursorY > 200) {
this.addNewPage();
this.cursorY = this.margin;
}
// Überschrift
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(11);
let title = 'K.-o.-Runde';
if (className) {
title = `${className} - ${title}`;
}
this.pdf.text(title, this.margin, this.cursorY);
this.cursorY += 7;
// Spiele-Tabelle
const head = [['Runde', 'Spieler 1', 'Spieler 2', 'Ergebnis', 'Sätze']];
const body = classMatches.map(m => [
m.round || '-',
getPlayerName(m.player1),
getPlayerName(m.player2),
m.isFinished ? (m.result || '-') : '-',
this.formatSets(m)
]);
autoTable(this.pdf, {
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
head,
body,
theme: 'grid',
styles: { fontSize: 9 },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
this.cursorY += 5;
});
}
}
getClassNameForId(classId, groupsByClass) {
// Versuche, den Klassennamen zu finden
// Dies sollte von außen übergeben werden, aber als Fallback:
return `Klasse ${classId}`;
}
findMatchResult(player1Id, player2Id, groupId) {
// Diese Methode sollte von außen übergeben werden
// Für jetzt: Rückgabe von null
return null;
}
formatSets(match) {
if (!match.tournamentResults || match.tournamentResults.length === 0) {
return '-';
}
return match.tournamentResults
.sort((a, b) => a.set - b.set)
.map(r => `${Math.abs(r.pointsPlayer1)}:${Math.abs(r.pointsPlayer2)}`)
.join(', ');
}
}
export default PDFGenerator;

View File

@@ -10,6 +10,7 @@ import DiaryView from './views/DiaryView.vue';
import PendingApprovalsView from './views/PendingApprovalsView.vue';
import ScheduleView from './views/ScheduleView.vue';
import TournamentsView from './views/TournamentsView.vue';
import ExternalTournamentsView from './views/ExternalTournamentsView.vue';
import TrainingStatsView from './views/TrainingStatsView.vue';
import ClubSettings from './views/ClubSettings.vue';
import PredefinedActivities from './views/PredefinedActivities.vue';
@@ -33,7 +34,8 @@ const routes = [
{ path: '/diary', component: DiaryView },
{ path: '/pending-approvals', component: PendingApprovalsView},
{ path: '/schedule', component: ScheduleView},
{ path: '/tournaments', component: TournamentsView },
{ path: '/tournaments', component: TournamentsView, props: { allowsExternal: false } },
{ path: '/external-tournaments', component: ExternalTournamentsView },
{ path: '/training-stats', component: TrainingStatsView },
{ path: '/club-settings', component: ClubSettings },
{ path: '/predefined-activities', component: PredefinedActivities },

View File

@@ -0,0 +1,14 @@
<template>
<TournamentsView :allowsExternal="true" />
</template>
<script>
import TournamentsView from './TournamentsView.vue';
export default {
name: 'ExternalTournamentsView',
components: {
TournamentsView
}
};
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="official-tournaments">
<h2>Offizielle Turniere</h2>
<h2>Turnierteilnahmen</h2>
<div v-if="list && list.length" class="list">
<div class="tabs">
<button :class="['tab', topActiveTab==='events' ? 'active' : '']" @click="switchTopTab('events')" title="Gespeicherte Veranstaltungen anzeigen">Veranstaltungen</button>

File diff suppressed because it is too large Load Diff