Add placements tab and localization support in TournamentTab

- Introduced a new tab for displaying tournament placements in the TournamentTab component.
- Added localization entries for placements in the German language JSON file, enhancing the user interface for German-speaking users.
- Updated the component structure to include the new TournamentPlacementsTab and ensure proper rendering based on the active tab.
This commit is contained in:
Torsten Schulz (local)
2025-11-29 00:23:34 +01:00
parent dc2c60cefe
commit 6acdcfa5c3
3 changed files with 512 additions and 22 deletions

View File

@@ -0,0 +1,458 @@
<template>
<div class="tab-content">
<TournamentClassSelector
v-if="selectedDate && selectedDate !== 'new'"
:model-value="selectedViewClass"
:tournament-classes="tournamentClasses"
:selected-date="selectedDate"
@update:modelValue="$emit('update:selectedViewClass', $event)"
/>
<!-- Endplatzierungen (K.O.-Runde) -->
<section v-if="finalPlacements.length > 0" class="final-placements">
<h3>{{ $t('tournaments.finalPlacements') }}</h3>
<template v-for="(classPlacements, classId) in finalPlacementsByClass" :key="`final-${classId}`">
<div v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))" class="class-section">
<h4 v-if="classId !== 'null' && classId !== 'undefined'" class="class-header">
{{ getClassName(classId) }}
</h4>
<table>
<thead>
<tr>
<th>{{ $t('tournaments.position') }}</th>
<th>{{ $t('tournaments.player') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="placement in classPlacements" :key="placement.id">
<td><strong>{{ placement.position }}.</strong></td>
<td>{{ placement.name }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>
<!-- Gruppenplatzierungen -->
<section v-if="groupPlacements.length > 0" class="group-placements">
<h3>{{ $t('tournaments.groupPlacements') }}</h3>
<template v-for="(classGroups, classId) in groupPlacementsByClass" :key="`group-${classId}`">
<div v-if="shouldShowClass(classId === 'null' ? null : parseInt(classId))" class="class-section">
<h4 v-if="classId !== 'null' && classId !== 'undefined'" class="class-header">
{{ getClassName(classId) }}
</h4>
<div v-for="group in classGroups" :key="group.groupId" class="group-table">
<h5>{{ $t('tournaments.groupNumber') }} {{ group.groupNumber }}</h5>
<table>
<thead>
<tr>
<th>{{ $t('tournaments.position') }}</th>
<th>{{ $t('tournaments.player') }}</th>
<th>{{ $t('tournaments.points') }}</th>
<th>{{ $t('tournaments.sets') }}</th>
<th>{{ $t('tournaments.diff') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(pl, idx) in group.rankings" :key="pl.id">
<td><strong>{{ pl.position }}.</strong></td>
<td><span v-if="pl.seeded" class="seeded-star"></span>{{ pl.name }}</td>
<td>{{ pl.points }}</td>
<td>{{ pl.setsWon }}:{{ pl.setsLost }}</td>
<td>{{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</section>
<div v-if="finalPlacements.length === 0 && groupPlacements.length === 0" class="no-placements">
<p>{{ $t('tournaments.noPlacementsYet') }}</p>
</div>
</div>
</template>
<script>
import TournamentClassSelector from './TournamentClassSelector.vue';
export default {
name: 'TournamentPlacementsTab',
components: {
TournamentClassSelector
},
props: {
selectedDate: {
type: [String, Number],
default: null
},
selectedViewClass: {
type: [Number, String, null],
default: null
},
tournamentClasses: {
type: Array,
required: true
},
knockoutMatches: {
type: Array,
required: true
},
groups: {
type: Array,
required: true
},
groupRankings: {
type: Object,
required: true
},
participants: {
type: Array,
required: true
},
externalParticipants: {
type: Array,
required: true
},
pairings: {
type: Array,
required: true
}
},
emits: [
'update:selectedViewClass'
],
computed: {
finalPlacements() {
// Extrahiere Endplatzierungen aus den K.O.-Matches
const placements = [];
const finishedMatches = this.knockoutMatches.filter(m => m.isFinished);
// Finde die Finale-Matches (letzte Runde)
const rounds = [...new Set(finishedMatches.map(m => m.round))];
if (rounds.length === 0) return [];
// Sortiere Runden nach Reihenfolge (Finale ist die letzte)
const roundOrder = ['Finale', 'Halbfinale', 'Viertelfinale', 'Achtelfinale'];
rounds.sort((a, b) => {
const aIdx = roundOrder.indexOf(a);
const bIdx = roundOrder.indexOf(b);
if (aIdx === -1 && bIdx === -1) return a.localeCompare(b);
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
});
const finalRound = rounds[rounds.length - 1];
const finalMatches = finishedMatches.filter(m => m.round === finalRound);
// Für jedes Finale-Match: Gewinner = Platz 1, Verlierer = Platz 2
finalMatches.forEach(match => {
const winner = this.getMatchWinner(match);
const loser = this.getMatchLoser(match);
if (winner) {
placements.push({
id: `final-${match.id}-winner`,
classId: match.classId,
position: 1,
name: winner,
matchId: match.id
});
}
if (loser) {
placements.push({
id: `final-${match.id}-loser`,
classId: match.classId,
position: 2,
name: loser,
matchId: match.id
});
}
});
// Wenn es Halbfinale gibt, finde die Verlierer (Platz 3)
const semiFinalRound = rounds.find(r => r.includes('Halbfinale'));
if (semiFinalRound) {
const semiFinalMatches = finishedMatches.filter(m => m.round === semiFinalRound);
semiFinalMatches.forEach(match => {
const loser = this.getMatchLoser(match);
if (loser) {
placements.push({
id: `semi-${match.id}-loser`,
classId: match.classId,
position: 3,
name: loser,
matchId: match.id
});
}
});
}
return placements.sort((a, b) => {
if (a.classId !== b.classId) {
const aNum = a.classId || 999999;
const bNum = b.classId || 999999;
return aNum - bNum;
}
return a.position - b.position;
});
},
finalPlacementsByClass() {
const grouped = {};
this.finalPlacements.forEach(p => {
const key = p.classId || 'null';
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(p);
});
return grouped;
},
groupPlacements() {
// Extrahiere Gruppenplatzierungen
const placements = [];
this.groups.forEach(group => {
const rankings = this.groupRankings[group.groupId] || [];
if (rankings.length > 0) {
placements.push({
groupId: group.groupId,
groupNumber: group.groupNumber,
classId: group.classId,
rankings: rankings.map(r => ({
id: r.id,
position: r.position,
name: r.name,
seeded: r.seeded,
points: r.points,
setsWon: r.setsWon,
setsLost: r.setsLost,
setDiff: r.setDiff
}))
});
}
});
return placements.sort((a, b) => {
if (a.classId !== b.classId) {
const aNum = a.classId || 999999;
const bNum = b.classId || 999999;
return aNum - bNum;
}
return a.groupNumber - b.groupNumber;
});
},
groupPlacementsByClass() {
const grouped = {};
this.groupPlacements.forEach(p => {
const key = p.classId || 'null';
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(p);
});
return grouped;
}
},
methods: {
shouldShowClass(classId) {
if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '__none__') {
return true;
}
const selectedId = typeof this.selectedViewClass === 'string' ? parseInt(this.selectedViewClass) : this.selectedViewClass;
return classId === selectedId;
},
getClassName(classId) {
if (classId === null || classId === '__none__' || classId === 'null' || classId === 'undefined' || classId === undefined) {
return this.$t('tournaments.withoutClass');
}
try {
const classIdNum = typeof classId === 'string' ? parseInt(classId) : classId;
const classItem = this.tournamentClasses.find(c => c.id === classIdNum);
return classItem ? classItem.name : '';
} catch (e) {
return '';
}
},
getMatchWinner(match) {
if (!match.isFinished || !match.tournamentResults || match.tournamentResults.length === 0) {
return null;
}
const results = match.tournamentResults || [];
let win1 = 0, win2 = 0;
results.forEach(r => {
if (r.pointsPlayer1 > r.pointsPlayer2) win1++;
else if (r.pointsPlayer2 > r.pointsPlayer1) win2++;
});
if (win1 > win2) {
return this.getPlayerName(match.player1, match);
} else if (win2 > win1) {
return this.getPlayerName(match.player2, match);
}
return null;
},
getMatchLoser(match) {
if (!match.isFinished || !match.tournamentResults || match.tournamentResults.length === 0) {
return null;
}
const results = match.tournamentResults || [];
let win1 = 0, win2 = 0;
results.forEach(r => {
if (r.pointsPlayer1 > r.pointsPlayer2) win1++;
else if (r.pointsPlayer2 > r.pointsPlayer1) win2++;
});
if (win1 > win2) {
const names = this.getMatchPlayerNames(match);
return names ? names.name2 : null;
} else if (win2 > win1) {
const names = this.getMatchPlayerNames(match);
return names ? names.name1 : null;
}
return null;
},
getPlayerName(player, match) {
if (!player && !match) return this.$t('tournaments.unknown');
// Prüfe ob es ein Doppel ist
if (match && match.classId) {
const tournamentClass = this.tournamentClasses.find(c => c.id === match.classId);
if (tournamentClass && tournamentClass.isDoubles) {
// Finde die Paarung basierend auf player1Id oder player2Id
const playerId = player?.id || (match.player1Id === player?.id ? match.player1Id : match.player2Id);
const pairing = this.pairings.find(p =>
p.classId === match.classId &&
(p.member1Id === playerId || p.member2Id === playerId ||
p.external1Id === playerId || p.external2Id === playerId)
);
if (pairing) {
const name1 = this.getPairingPlayerName(pairing, 1);
const name2 = this.getPairingPlayerName(pairing, 2);
return `${name1} / ${name2}`;
}
}
}
// Normale Spieler
if (player) {
if (player.member) {
return `${player.member.firstName} ${player.member.lastName}`;
} else if (player.firstName && player.lastName) {
return `${player.firstName} ${player.lastName}`;
}
}
return this.$t('tournaments.unknown');
},
getPairingPlayerName(pairing, playerNumber) {
if (playerNumber === 1) {
if (pairing.member1 && pairing.member1.member) {
return `${pairing.member1.member.firstName} ${pairing.member1.member.lastName}`;
} else if (pairing.external1) {
return `${pairing.external1.firstName} ${pairing.external1.lastName}`;
}
} else if (playerNumber === 2) {
if (pairing.member2 && pairing.member2.member) {
return `${pairing.member2.member.firstName} ${pairing.member2.member.lastName}`;
} else if (pairing.external2) {
return `${pairing.external2.firstName} ${pairing.external2.lastName}`;
}
}
return this.$t('tournaments.unknown');
},
getMatchPlayerNames(match) {
const classId = match.classId;
if (classId) {
const tournamentClass = this.tournamentClasses.find(c => c.id === classId);
if (tournamentClass && tournamentClass.isDoubles) {
const pairing1 = this.pairings.find(p =>
p.classId === classId &&
(p.member1Id === match.player1Id || p.external1Id === match.player1Id ||
p.member2Id === match.player1Id || p.external2Id === match.player1Id)
);
const pairing2 = this.pairings.find(p =>
p.classId === classId &&
(p.member1Id === match.player2Id || p.external1Id === match.player2Id ||
p.member2Id === match.player2Id || p.external2Id === match.player2Id)
);
if (pairing1 && pairing2) {
const name1 = this.getPairingPlayerName(pairing1, 1) + ' / ' + this.getPairingPlayerName(pairing1, 2);
const name2 = this.getPairingPlayerName(pairing2, 1) + ' / ' + this.getPairingPlayerName(pairing2, 2);
return { name1, name2 };
}
}
}
return {
name1: this.getPlayerName(match.player1, match),
name2: this.getPlayerName(match.player2, match)
};
}
}
};
</script>
<style scoped>
.final-placements,
.group-placements {
margin-bottom: 2rem;
}
.class-section {
margin-bottom: 2rem;
}
.class-header {
margin-bottom: 1rem;
color: #333;
font-size: 1.1em;
}
.group-table {
margin-bottom: 1.5rem;
}
.group-table h5 {
margin-bottom: 0.5rem;
color: #666;
font-size: 1em;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
thead {
background-color: #f5f5f5;
}
th, td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
font-weight: 600;
}
.seeded-star {
color: #ff9800;
margin-right: 0.25rem;
}
.no-placements {
text-align: center;
padding: 2rem;
color: #666;
}
</style>

View File

@@ -589,6 +589,10 @@
"tabGroups": "Gruppen",
"tabParticipants": "Teilnehmer",
"tabResults": "Ergebnisse",
"tabPlacements": "Platzierungen",
"finalPlacements": "Endplatzierungen",
"groupPlacements": "Gruppenplatzierungen",
"noPlacementsYet": "Noch keine Platzierungen vorhanden.",
"participants": "Teilnehmer",
"seeded": "Gesetzt",
"club": "Verein",

View File

@@ -61,6 +61,11 @@
:class="['tab-button', { 'active': activeTab === 'results' }]">
{{ $t('tournaments.tabResults') }}
</button>
<button
@click="setActiveTab('placements')"
:class="['tab-button', { 'active': activeTab === 'placements' }]">
{{ $t('tournaments.tabPlacements') }}
</button>
</div>
<!-- Tab: Konfiguration -->
@@ -203,6 +208,21 @@
@start-knockout="startKnockout"
@reset-knockout="resetKnockout"
/>
<!-- Tab: Platzierungen -->
<TournamentPlacementsTab
v-if="activeTab === 'placements'"
:selected-date="selectedDate"
:selected-view-class="selectedViewClass"
:tournament-classes="tournamentClasses"
:knockout-matches="knockoutMatches"
:groups="groups"
:group-rankings="groupRankings"
:participants="participants"
:external-participants="externalParticipants"
:pairings="pairings"
@update:selectedViewClass="selectedViewClass = $event"
/>
</div>
</div>
@@ -239,6 +259,7 @@ import TournamentConfigTab from '../components/tournament/TournamentConfigTab.vu
import TournamentGroupsTab from '../components/tournament/TournamentGroupsTab.vue';
import TournamentParticipantsTab from '../components/tournament/TournamentParticipantsTab.vue';
import TournamentResultsTab from '../components/tournament/TournamentResultsTab.vue';
import TournamentPlacementsTab from '../components/tournament/TournamentPlacementsTab.vue';
export default {
name: 'TournamentTab',
components: {
@@ -247,7 +268,8 @@ export default {
TournamentConfigTab,
TournamentGroupsTab,
TournamentParticipantsTab,
TournamentResultsTab
TournamentResultsTab,
TournamentPlacementsTab
},
props: {
allowsExternal: {
@@ -1670,28 +1692,34 @@ export default {
},
async removeParticipant(p) {
if (this.allowsExternal && p.isExternal) {
// Externer Teilnehmer
await apiClient.delete('/tournament/external-participant', {
data: {
clubId: this.currentClub,
tournamentId: this.selectedDate,
participantId: p.id
}
});
this.externalParticipants = this.externalParticipants.filter(x => x.id !== p.id);
} else {
// Interner Teilnehmer
await apiClient.delete('/tournament/participant', {
data: {
clubId: this.currentClub,
tournamentId: this.selectedDate,
participantId: p.id
}
});
this.participants = this.participants.filter(x => x.id !== p.id);
try {
if (this.allowsExternal && p.isExternal) {
// Externer Teilnehmer
await apiClient.delete('/tournament/external-participant', {
data: {
clubId: this.currentClub,
tournamentId: this.selectedDate,
participantId: p.id
}
});
this.externalParticipants = this.externalParticipants.filter(x => x.id !== p.id);
} else {
// Interner Teilnehmer
await apiClient.delete('/tournament/participant', {
data: {
clubId: this.currentClub,
tournamentId: this.selectedDate,
participantId: p.id
}
});
this.participants = this.participants.filter(x => x.id !== p.id);
}
await this.loadTournamentData();
} catch (error) {
console.error('Fehler beim Entfernen des Teilnehmers:', error);
const message = safeErrorMessage(error, 'Fehler beim Entfernen des Teilnehmers.');
await this.showInfo('Fehler', message, '', 'error');
}
await this.loadTournamentData();
},
async onGroupCountChange() {