Verbessert die Logik zur Zuordnung von Teilnehmern in TournamentService.js, indem manuelle Zuordnungen berücksichtigt werden. Implementiert eine zufällige Verteilung der Teilnehmer nur, wenn keine manuelle Zuordnung vorhanden ist. Aktualisiert die Erstellung von Matches, um sicherzustellen, dass nur Spieler aus derselben Gruppe gegeneinander antreten. In TournamentsView.vue wird die Teilnehmerliste jetzt kollabierbar, und es werden neue Funktionen zur Anzeige von Spielergebnissen und zur Hervorhebung von Matches hinzugefügt.

This commit is contained in:
Torsten Schulz (local)
2025-09-21 17:16:47 +02:00
parent 312f8f24ab
commit 561d8186d3
2 changed files with 509 additions and 56 deletions

View File

@@ -180,32 +180,52 @@ class TournamentService {
// 2) Alte Matches löschen
await TournamentMatch.destroy({ where: { tournamentId } });
// 3) Shuffle + verteilen
const shuffled = members.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
// 3) Prüfe, ob Spieler bereits manuell zugeordnet wurden
const alreadyAssigned = members.filter(m => m.groupId !== null);
const unassigned = members.filter(m => m.groupId === null);
if (alreadyAssigned.length > 0) {
// Spieler sind bereits manuell zugeordnet - nicht neu verteilen
console.log(`${alreadyAssigned.length} Spieler bereits zugeordnet, ${unassigned.length} noch nicht zugeordnet`);
} else {
// Keine manuellen Zuordnungen - zufällig verteilen
const shuffled = members.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
groups.forEach((g, idx) => {
shuffled
.filter((_, i) => i % groups.length === idx)
.forEach(m => m.update({ groupId: g.id }));
});
}
groups.forEach((g, idx) => {
shuffled
.filter((_, i) => i % groups.length === idx)
.forEach(m => m.update({ groupId: g.id }));
});
// 4) RoundRobin anlegen wie gehabt
// 4) RoundRobin anlegen wie gehabt - NUR innerhalb jeder Gruppe
for (const g of groups) {
const gm = await TournamentMember.findAll({ where: { groupId: g.id } });
if (gm.length < 2) {
console.warn(`Gruppe ${g.id} hat nur ${gm.length} Teilnehmer - keine Matches erstellt`);
continue;
}
const rounds = this.generateRoundRobinSchedule(gm);
for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) {
for (const [p1Id, p2Id] of rounds[roundIndex]) {
await TournamentMatch.create({
tournamentId,
groupId: g.id,
round: 'group',
player1Id: p1Id,
player2Id: p2Id,
groupRound: roundIndex + 1
});
// Prüfe, ob beide Spieler zur gleichen Gruppe gehören
const p1 = gm.find(p => p.id === p1Id);
const p2 = gm.find(p => p.id === p2Id);
if (p1 && p2 && p1.groupId === p2.groupId && p1.groupId === g.id) {
await TournamentMatch.create({
tournamentId,
groupId: g.id,
round: 'group',
player1Id: p1Id,
player2Id: p2Id,
groupRound: roundIndex + 1
});
} else {
console.warn(`Spieler gehören nicht zur gleichen Gruppe: ${p1Id} (${p1?.groupId}) vs ${p2Id} (${p2?.groupId}) in Gruppe ${g.id}`);
}
}
}
}

View File

@@ -24,34 +24,41 @@
<span>Spielen in Gruppen</span>
</label>
<section class="participants">
<h4>Teilnehmer</h4>
<ul>
<li v-for="participant in participants" :key="participant.id">
{{ participant.member.firstName }}
{{ participant.member.lastName }}
<template v-if="isGroupTournament">
<label class="inline-label">
Gruppe:
<select v-model.number="participant.groupNumber">
<option :value="null"></option>
<option v-for="group in groups" :key="group.groupId" :value="group.groupNumber">
Gruppe {{ group.groupNumber }}
</option>
</select>
</label>
</template>
<button @click="removeParticipant(participant)" style="margin-left:0.5rem" class="trash-btn">
🗑
</button>
</li>
</ul>
<select v-model="selectedMember">
<option v-for="member in clubMembers" :key="member.id" :value="member.id">
{{ member.firstName }}
{{ member.lastName }}
</option>
</select>
<button @click="addParticipant">Hinzufügen</button>
<div class="participants-header" @click="toggleParticipants">
<h4>Teilnehmer</h4>
<span class="collapse-icon" :class="{ 'expanded': showParticipants }"></span>
</div>
<div v-show="showParticipants" class="participants-content">
<ul>
<li v-for="participant in participants" :key="participant.id">
{{ participant.member?.firstName || 'Unbekannt' }}
{{ participant.member?.lastName || '' }}
<template v-if="isGroupTournament">
<label class="inline-label">
Gruppe:
<select v-model.number="participant.groupNumber" @change="updateParticipantGroup(participant, $event)">
<option :value="null"></option>
<option v-for="group in groups" :key="group.groupId" :value="group.groupNumber">
Gruppe {{ group.groupNumber }}
</option>
</select>
</label>
</template>
<button @click="removeParticipant(participant)" style="margin-left:0.5rem" class="trash-btn">
🗑
</button>
</li>
</ul>
<div class="add-participant">
<select v-model="selectedMember">
<option v-for="member in clubMembers" :key="member.id" :value="member.id">
{{ member.firstName }}
{{ member.lastName }}
</option>
</select>
<button @click="addParticipant">Hinzufügen</button>
</div>
</div>
</section>
<section v-if="isGroupTournament" class="group-controls">
<label>
@@ -78,22 +85,38 @@
<table>
<thead>
<tr>
<th>Platz</th>
<th>Index</th>
<th>Spieler</th>
<th>Punkte</th>
<th>Satz</th>
<th>Diff</th>
<th v-for="(opponent, idx) in groupRankings[group.groupId]" :key="`opp-${opponent.id}`">
G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}
</th>
<th>Platz</th>
</tr>
</thead>
<tbody>
<tr v-for="pl in groupRankings[group.groupId]" :key="pl.id">
<td>{{ pl.position }}.</td>
<tr v-for="(pl, idx) in groupRankings[group.groupId]" :key="pl.id">
<td><strong>G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}</strong></td>
<td>{{ pl.name }}</td>
<td>{{ pl.points }}</td>
<td>{{ pl.setsWon }}:{{ pl.setsLost }}</td>
<td>
{{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}
</td>
<td v-for="(opponent, oppIdx) in groupRankings[group.groupId]"
:key="`match-${pl.id}-${opponent.id}`"
:class="['match-cell', { 'clickable': idx !== oppIdx }]"
@click="idx !== oppIdx ? highlightMatch(pl.id, opponent.id, group.groupId) : null">
<span v-if="idx === oppIdx" class="diagonal"></span>
<span v-else-if="getMatchLiveResult(pl.id, opponent.id, group.groupId)"
:class="getMatchCellClasses(pl.id, opponent.id, group.groupId)">
{{ getMatchDisplayText(pl.id, opponent.id, group.groupId) }}
</span>
<span v-else class="no-match">-</span>
</td>
<td>{{ pl.position }}.</td>
</tr>
</tbody>
</table>
@@ -122,7 +145,7 @@
</tr>
</thead>
<tbody>
<tr v-for="m in groupMatches" :key="m.id">
<tr v-for="m in groupMatches" :key="m.id" :data-match-id="m.id">
<td>{{ m.groupRound }}</td>
<td>{{ m.groupNumber }}</td>
<td>
@@ -301,6 +324,7 @@ export default {
groups: [],
matches: [],
showKnockout: false,
showParticipants: false, // Kollaps-Status für Teilnehmerliste
editingResult: {
matchId: null, // aktuell bearbeitetes Match
set: null, // aktuell bearbeitete SatzNummer
@@ -369,6 +393,15 @@ export default {
return rankings;
},
// Mapping von groupId zu groupNumber für die Teilnehmer-Auswahl
groupIdToNumberMap() {
const map = {};
this.groups.forEach(g => {
map[g.groupId] = g.groupNumber;
});
return map;
},
rankingList() {
const finalMatch = this.knockoutMatches.find(
m => m.round.toLowerCase() === 'finale'
@@ -435,6 +468,20 @@ export default {
);
this.clubMembers = m.data;
},
mounted() {
// Event-Listener für das Entfernen des Highlights
document.addEventListener('click', (e) => {
// Entferne Highlight nur wenn nicht auf eine Matrix-Zelle geklickt wird
if (!e.target.closest('.match-cell')) {
this.clearHighlight();
}
});
// Event-Listener für Eingabefelder
document.addEventListener('input', () => {
this.clearHighlight();
});
},
methods: {
normalizeResultInput(raw) {
const s = raw.trim();
@@ -493,11 +540,27 @@ export default {
m[g.groupId] = g.groupNumber;
return m;
}, {});
this.matches = mRes.data.map(m => ({
...m,
groupNumber: grpMap[m.groupId] || 0,
resultInput: ''
}));
this.matches = mRes.data.map(m => {
// Bestimme groupId basierend auf den Spielern, da die Matches groupId: null haben
const player1GroupId = m.player1?.groupId;
const player2GroupId = m.player2?.groupId;
const matchGroupId = player1GroupId || player2GroupId;
return {
...m,
groupId: matchGroupId, // Überschreibe null mit der korrekten groupId
groupNumber: grpMap[matchGroupId] || 0,
resultInput: ''
};
});
// Initialisiere groupNumber für Teilnehmer basierend auf groupId
this.initializeParticipantGroupNumbers();
// 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');
},
@@ -785,6 +848,207 @@ export default {
} catch (err) {
alert('Fehler beim Zurücksetzen der K.o.-Runde');
}
},
getMatchResult(player1Id, player2Id, groupId) {
const match = this.matches.find(m =>
m.round === 'group' &&
m.groupId === groupId &&
((m.player1.id === player1Id && m.player2.id === player2Id) ||
(m.player1.id === player2Id && m.player2.id === player1Id)) &&
m.isFinished
);
if (!match) return null;
// Bestimme, wer gewonnen hat
const [sets1, sets2] = match.result.split(':').map(n => +n);
const player1Won = sets1 > sets2;
// Gib das Ergebnis in der Sicht des ersten Spielers zurück
if (match.player1.id === player1Id) {
return player1Won ? 'W' : 'L';
} else {
return player1Won ? 'L' : 'W';
}
},
getMatchLiveResult(player1Id, player2Id, groupId) {
const match = this.matches.find(m =>
m.round === 'group' &&
m.groupId === groupId &&
((m.player1.id === player1Id && m.player2.id === player2Id) ||
(m.player1.id === player2Id && m.player2.id === player1Id))
);
if (!match) return null;
// Berechne aktuelle Sätze aus tournamentResults
let sets1 = 0, sets2 = 0;
if (match.tournamentResults && match.tournamentResults.length > 0) {
match.tournamentResults.forEach(result => {
if (result.pointsPlayer1 > result.pointsPlayer2) {
sets1++;
} else if (result.pointsPlayer2 > result.pointsPlayer1) {
sets2++;
}
});
}
// Bestimme die Anzeige basierend auf der Spieler-Reihenfolge
if (match.player1.id === player1Id) {
return {
sets1: sets1,
sets2: sets2,
isFinished: match.isFinished,
player1Won: sets1 > sets2,
player2Won: sets2 > sets1,
isTie: sets1 === sets2
};
} else {
return {
sets1: sets2,
sets2: sets1,
isFinished: match.isFinished,
player1Won: sets2 > sets1,
player2Won: sets1 > sets2,
isTie: sets1 === sets2
};
}
},
highlightMatch(player1Id, player2Id, groupId) {
console.log('highlightMatch called:', { player1Id, player2Id, groupId });
// Finde das entsprechende Match (auch unbeendete)
const match = this.matches.find(m =>
m.round === 'group' &&
m.groupId === groupId &&
((m.player1.id === player1Id && m.player2.id === player2Id) ||
(m.player1.id === player2Id && m.player2.id === player1Id))
);
console.log('Found match:', match);
if (!match) {
console.log('No match found');
return;
}
// Setze Highlight-Klasse
this.$nextTick(() => {
const matchElement = document.querySelector(`tr[data-match-id="${match.id}"]`);
console.log('Match element:', matchElement);
if (matchElement) {
// Entferne vorherige Highlights
document.querySelectorAll('.match-highlight').forEach(el => {
el.classList.remove('match-highlight');
});
// Füge Highlight hinzu
matchElement.classList.add('match-highlight');
// Scrolle zum Element
matchElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
} else {
console.log('Match element not found in DOM');
}
});
},
clearHighlight() {
// Entferne alle Highlights
document.querySelectorAll('.match-highlight').forEach(el => {
el.classList.remove('match-highlight');
});
},
async updateParticipantGroup(participant, event) {
const groupNumber = parseInt(event.target.value);
// Aktualisiere lokal
participant.groupNumber = groupNumber;
// Bereite alle Teilnehmer-Zuordnungen vor
const assignments = this.participants.map(p => ({
participantId: p.id,
groupNumber: p.groupNumber || null
}));
// Sende an Backend
try {
await apiClient.post('/tournament/groups/manual', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
assignments: assignments,
numberOfGroups: this.numberOfGroups,
maxGroupSize: this.maxGroupSize
});
// Lade Daten neu, um die aktualisierten Gruppen zu erhalten
await this.loadTournamentData();
} catch (error) {
console.error('Fehler beim Aktualisieren der Gruppe:', error);
// Bei Fehler: Lade Daten neu
await this.loadTournamentData();
}
},
// Initialisiere groupNumber basierend auf groupId
initializeParticipantGroupNumbers() {
this.participants.forEach(participant => {
if (participant.groupId) {
const group = this.groups.find(g => g.groupId === participant.groupId);
if (group) {
participant.groupNumber = group.groupNumber;
}
}
});
},
toggleParticipants() {
this.showParticipants = !this.showParticipants;
},
getMatchDisplayText(player1Id, player2Id, groupId) {
const liveResult = this.getMatchLiveResult(player1Id, player2Id, groupId);
if (!liveResult) return '-';
// Zeige Satzergebnis (z.B. 1:0, 2:1, 0:2)
return `${liveResult.sets1}:${liveResult.sets2}`;
},
getMatchCellClasses(player1Id, player2Id, groupId) {
const liveResult = this.getMatchLiveResult(player1Id, player2Id, groupId);
if (!liveResult) return ['no-match'];
const classes = ['match-result'];
if (liveResult.isFinished) {
// Spiel beendet: Dunkle Farben
if (liveResult.player1Won) {
classes.push('match-finished-win');
} else if (liveResult.player2Won) {
classes.push('match-finished-loss');
} else {
classes.push('match-finished-tie');
}
} else {
// Spiel läuft: Helle Farben
if (liveResult.player1Won) {
classes.push('match-live-win');
} else if (liveResult.player2Won) {
classes.push('match-live-loss');
} else {
classes.push('match-live-tie');
}
}
return classes;
}
}
};
@@ -822,4 +1086,173 @@ td {
button {
margin-left: 0.5em;
}
.diagonal {
background-color: #000;
color: #000;
display: block;
width: 100%;
height: 100%;
min-height: 20px;
}
.match-result {
font-weight: bold;
padding: 2px 4px;
border-radius: 3px;
}
.match-result.win {
background-color: #d4edda;
color: #155724;
}
.match-result.loss {
background-color: #f8d7da;
color: #721c24;
}
/* Live-Ergebnisse während des Spiels */
.match-live-win {
background-color: #d1eca1; /* Hellgrün */
color: #0c5460;
font-weight: bold;
}
.match-live-loss {
background-color: #ffeaa7; /* Orange */
color: #d63031;
font-weight: bold;
}
.match-live-tie {
background-color: #f8f9fa; /* Neutral */
color: #6c757d;
font-weight: bold;
}
/* Beendete Spiele */
.match-finished-win {
background-color: #28a745; /* Dunkelgrün */
color: white;
font-weight: bold;
}
.match-finished-loss {
background-color: #fd7e14; /* Dunkelorange */
color: white;
font-weight: bold;
}
.match-finished-tie {
background-color: #6c757d; /* Grau */
color: white;
font-weight: bold;
}
/* Kollabierbare Teilnehmerliste */
.participants-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 0.5rem 0;
border-bottom: 1px solid #dee2e6;
margin-bottom: 1rem;
}
.participants-header:hover {
background-color: #f8f9fa;
border-radius: 4px;
}
.participants-header h4 {
margin: 0;
color: #495057;
}
.collapse-icon {
font-size: 0.8em;
color: #6c757d;
transition: transform 0.3s ease;
user-select: none;
}
.collapse-icon.expanded {
transform: rotate(180deg);
}
.participants-content {
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 1000px;
}
}
.add-participant {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #dee2e6;
display: flex;
gap: 0.5rem;
align-items: center;
}
.no-match {
color: #ccc;
}
.group-table table {
font-size: 0.9em;
}
.group-table th,
.group-table td {
padding: 0.3em 0.5em;
text-align: center;
}
.group-table th:first-child,
.group-table td:first-child {
text-align: left;
font-weight: bold;
}
.group-table th:nth-child(2),
.group-table td:nth-child(2) {
text-align: left;
min-width: 120px;
}
.match-cell.clickable {
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.match-cell.clickable:hover {
background-color: #f8f9fa;
transform: scale(1.02);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.match-highlight {
background-color: #fff3cd !important;
border: 2px solid #ffc107 !important;
animation: highlight-pulse 0.5s ease-in-out;
}
@keyframes highlight-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.02); }
100% { transform: scale(1); }
}
</style>