feat(tournament): add player details dialog and enhance player name interactions

- Implemented clickable player names in the TournamentPlacementsTab for improved user experience.
- Added a PlayerDetailsDialog component to display detailed player information when names are clicked.
- Updated localization files to include new strings for player details.
- Enhanced data handling for internal and external participants in player dialog logic.
This commit is contained in:
Torsten Schulz (local)
2025-12-17 14:31:36 +01:00
parent dc084806ab
commit dd0f29124c
4 changed files with 557 additions and 7 deletions

View File

@@ -0,0 +1,366 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:title="playerName"
:is-modal="true"
size="medium"
@close="handleClose"
>
<div v-if="!loading && playerData" class="player-details-content">
<table class="player-details-table">
<tbody>
<tr v-if="playerData.name">
<td class="label-cell">{{ $t('members.firstName') }} / {{ $t('members.lastName') }}</td>
<td class="value-cell">
<button class="copy-button" @click="copyToClipboard(playerData.name)" title="Kopieren">📋</button>
{{ playerData.name }}
</td>
</tr>
<tr v-if="playerData.birthDate">
<td class="label-cell">{{ $t('members.birthdate') }}</td>
<td class="value-cell">
<button class="copy-button" @click="copyToClipboard(formatDate(playerData.birthDate))" title="Kopieren">📋</button>
{{ formatDate(playerData.birthDate) }}
</td>
</tr>
<tr v-if="playerData.address">
<td class="label-cell">{{ $t('tournaments.address') }}</td>
<td class="value-cell">
<button class="copy-button" @click="copyToClipboard(playerData.address)" title="Kopieren">📋</button>
{{ playerData.address }}
</td>
</tr>
<tr v-if="playerData.gender && playerData.gender !== 'unknown'">
<td class="label-cell">{{ $t('members.gender') }}</td>
<td class="value-cell">
<button class="copy-button" @click="copyToClipboard(formatGender(playerData.gender))" title="Kopieren">📋</button>
{{ formatGender(playerData.gender) }}
</td>
</tr>
<tr v-if="playerData.email">
<td class="label-cell">{{ $t('members.emailAddress') }}</td>
<td class="value-cell">
<button class="copy-button" @click="copyToClipboard(playerData.email)" title="Kopieren">📋</button>
{{ playerData.email }}
</td>
</tr>
<tr v-if="playerData.phone">
<td class="label-cell">{{ $t('members.phoneNumber') }}</td>
<td class="value-cell">
<button class="copy-button" @click="copyToClipboard(playerData.phone)" title="Kopieren">📋</button>
{{ playerData.phone }}
</td>
</tr>
</tbody>
</table>
<div v-if="!hasAnyData" class="no-data">
{{ $t('tournaments.noPlayerDataAvailable') }}
</div>
</div>
<div v-else-if="loading" class="loading">
{{ $t('messages.loading') }}...
</div>
<div v-else class="loading">
{{ $t('tournaments.noPlayerDataAvailable') }}
</div>
</BaseDialog>
</template>
<script>
import BaseDialog from '../BaseDialog.vue';
import apiClient from '../../apiClient.js';
export default {
name: 'PlayerDetailsDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
playerId: {
type: Number,
default: null
},
isExternal: {
type: Boolean,
default: false
},
tournamentId: {
type: [Number, String],
required: true
},
clubId: {
type: [Number, String],
required: true
},
playerName: {
type: String,
default: ''
}
},
emits: ['update:modelValue'],
data() {
return {
playerData: null,
loading: false
};
},
computed: {
hasAnyData() {
if (!this.playerData) return false;
return !!(this.playerData.name || this.playerData.birthDate || this.playerData.address ||
this.playerData.gender || this.playerData.email || this.playerData.phone);
}
},
watch: {
modelValue(newVal) {
console.log('[PlayerDetailsDialog] modelValue changed:', newVal, 'playerId:', this.playerId);
if (newVal && this.playerId) {
// Lade Daten erst, wenn der Dialog geöffnet wird
this.loadPlayerData();
} else {
this.playerData = null;
}
},
playerId(newVal) {
console.log('[PlayerDetailsDialog] playerId changed:', newVal, 'modelValue:', this.modelValue);
// Wenn der Dialog bereits geöffnet ist und die playerId sich ändert, lade neue Daten
if (this.modelValue && newVal) {
this.loadPlayerData();
}
}
},
methods: {
async loadPlayerData() {
console.log('[PlayerDetailsDialog] loadPlayerData called, playerId:', this.playerId, 'isExternal:', this.isExternal, 'clubId:', this.clubId);
if (!this.playerId) {
console.warn('[PlayerDetailsDialog] Keine playerId');
return;
}
if (!this.clubId || isNaN(Number(this.clubId))) {
console.error('[PlayerDetailsDialog] Invalid clubId:', this.clubId);
this.playerData = {
name: this.playerName,
error: true
};
this.loading = false;
return;
}
this.loading = true;
this.playerData = null;
try {
if (this.isExternal) {
// Lade externe Teilnehmer-Daten
// Lade alle externen Teilnehmer für dieses Turnier (ohne classId Filter = alle Klassen)
const response = await apiClient.post('/tournament/external-participants', {
clubId: Number(this.clubId),
tournamentId: this.tournamentId,
classId: null // null = alle externen Teilnehmer des Turniers
});
const externalParticipant = Array.isArray(response.data)
? response.data.find(p => p.id === this.playerId)
: null;
if (externalParticipant) {
this.playerData = {
name: `${externalParticipant.firstName || ''} ${externalParticipant.lastName || ''}`.trim(),
birthDate: externalParticipant.birthDate || null,
address: null, // Externe Teilnehmer haben keine Adresse
gender: externalParticipant.gender || null,
email: null, // Externe Teilnehmer haben keine E-Mail
phone: null // Externe Teilnehmer haben keine Telefonnummer
};
} else {
this.playerData = {
name: this.playerName,
error: true
};
}
} else {
// Lade interne Member-Daten
const response = await apiClient.get(`/clubmembers/get/${Number(this.clubId)}/true`);
const member = response.data.find(m => m.id === this.playerId);
if (member) {
// Formatiere Adresse
let address = '';
const parts = [];
if (member.street) parts.push(member.street);
if (member.postalCode) parts.push(member.postalCode);
if (member.city) parts.push(member.city);
address = parts.join(', ');
// Formatiere Telefonnummer
let phone = '';
if (member.contacts && Array.isArray(member.contacts)) {
const phoneContacts = member.contacts
.filter(c => c.type === 'phone')
.map(c => c.value);
phone = phoneContacts.join(', ');
} else if (member.phone) {
phone = member.phone;
}
// Formatiere E-Mail
let email = '';
if (member.contacts && Array.isArray(member.contacts)) {
const emailContacts = member.contacts
.filter(c => c.type === 'email')
.map(c => c.value);
email = emailContacts.join(', ');
} else if (member.email) {
email = member.email;
}
this.playerData = {
name: `${member.firstName || ''} ${member.lastName || ''}`.trim(),
birthDate: member.birthDate || null,
address: address || null,
gender: member.gender || null,
email: email || null,
phone: phone || null
};
}
}
} catch (error) {
console.error('Fehler beim Laden der Spielerdaten:', error);
this.playerData = {
name: this.playerName,
error: true
};
} finally {
this.loading = false;
}
},
formatDate(dateString) {
if (!dateString) return '';
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
// Versuche, verschiedene Datumsformate zu parsen
const ddmmyyyy = dateString.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
if (ddmmyyyy) {
return dateString; // Bereits im richtigen Format
}
return dateString;
}
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}.${month}.${year}`;
} catch (e) {
return dateString;
}
},
formatGender(gender) {
if (!gender || gender === 'unknown') return '';
const genderMap = {
'male': this.$t('members.genderMale'),
'female': this.$t('members.genderFemale'),
'diverse': this.$t('members.genderDiverse')
};
return genderMap[gender] || gender;
},
handleClose() {
this.$emit('update:modelValue', false);
},
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
// Optional: Feedback anzeigen (z.B. kurz "Kopiert!" anzeigen)
} catch (err) {
console.error('Fehler beim Kopieren:', err);
// Fallback für ältere Browser
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
} catch (fallbackErr) {
console.error('Fallback-Kopieren fehlgeschlagen:', fallbackErr);
}
document.body.removeChild(textArea);
}
}
}
};
</script>
<style scoped>
.player-details-content {
padding: 1rem;
}
.player-details-table {
width: 100%;
border-collapse: collapse;
}
.player-details-table tbody tr {
border-bottom: 1px solid #e0e0e0;
}
.player-details-table tbody tr:last-child {
border-bottom: none;
}
.player-details-table .label-cell {
padding: 0.75rem 1rem 0.75rem 0;
font-weight: 600;
color: #333;
vertical-align: top;
width: 40%;
min-width: 150px;
}
.player-details-table .value-cell {
padding: 0.75rem 0;
color: #666;
vertical-align: top;
display: flex;
align-items: center;
gap: 0.5rem;
}
.copy-button {
background: none;
border: none;
cursor: pointer;
font-size: 1.1em;
padding: 0.25rem 0.5rem;
opacity: 0.6;
transition: opacity 0.2s;
flex-shrink: 0;
}
.copy-button:hover {
opacity: 1;
}
.copy-button:active {
opacity: 0.8;
}
.no-data {
text-align: center;
padding: 2rem;
color: #999;
font-style: italic;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
</style>

View File

@@ -27,7 +27,15 @@
<tbody>
<tr v-for="(entry, entryIdx) in classPlacements" :key="`final-${classId}-${entryIdx}`">
<td class="col-place">{{ entry.position }}.</td>
<td>{{ getEntryPlayerName(entry) }}</td>
<td>
<span
class="player-name-clickable"
@click="openPlayerDialog(entry)"
:title="$t('tournaments.showPlayerDetails')"
>
{{ getEntryPlayerName(entry) }}
</span>
</td>
</tr>
</tbody>
</table>
@@ -59,7 +67,15 @@
<tbody>
<tr v-for="(r, ri) in g.rankings" :key="`r-${g.groupId}-${ri}`">
<td class="col-place">{{ r.position }}.</td>
<td>{{ r.name }}</td>
<td>
<span
class="player-name-clickable"
@click="openPlayerDialogFromRanking(r)"
:title="$t('tournaments.showPlayerDetails')"
>
{{ r.name }}
</span>
</td>
<td>{{ r.points }}</td>
<td>{{ r.setsWon }}:{{ r.setsLost }}</td>
<td>{{ r.setDiff >= 0 ? '+' + r.setDiff : r.setDiff }}</td>
@@ -73,15 +89,29 @@
<div v-if="Object.keys(finalPlacementsByClass).length === 0 && groupPlacements.length === 0" class="no-placements">
<p>{{ $t('tournaments.noPlacementsYet') }}</p>
</div>
<!-- Player Details Dialog -->
<PlayerDetailsDialog
v-model="showPlayerDialog"
:player-id="selectedPlayerId"
:is-external="selectedPlayerIsExternal"
:tournament-id="selectedDate"
:club-id="clubId"
:player-name="selectedPlayerName"
/>
</div>
</template>
<script>
import TournamentClassSelector from './TournamentClassSelector.vue';
import PlayerDetailsDialog from './PlayerDetailsDialog.vue';
export default {
name: 'TournamentPlacementsTab',
components: { TournamentClassSelector },
components: {
TournamentClassSelector,
PlayerDetailsDialog
},
props: {
selectedDate: { type: [String, Number], default: null },
selectedViewClass: { type: [Number, String, null], default: null },
@@ -91,9 +121,26 @@ export default {
pairings: { type: Array, required: true },
groups: { type: Array, required: true },
groupRankings: { type: Object, required: true },
knockoutMatches: { type: Array, required: true }
knockoutMatches: { type: Array, required: true },
clubId: { type: [Number, String], required: true }
},
emits: ['update:selectedViewClass'],
data() {
return {
showPlayerDialog: false,
selectedPlayerId: null,
selectedPlayerIsExternal: false,
selectedPlayerName: ''
};
},
watch: {
showPlayerDialog(newVal) {
console.log('[TournamentPlacementsTab] showPlayerDialog changed:', newVal);
},
selectedPlayerId(newVal) {
console.log('[TournamentPlacementsTab] selectedPlayerId changed:', newVal);
}
},
computed: {
// Flag für 'Alle Klassen'
isAllSelected() {
@@ -379,7 +426,8 @@ export default {
points: r.points,
setsWon: r.setsWon,
setsLost: r.setsLost,
setDiff: r.setDiff
setDiff: r.setDiff,
isExternal: r.isExternal || false
}))
});
}
@@ -402,7 +450,8 @@ export default {
points: r.points,
setsWon: r.setsWon,
setsLost: r.setsLost,
setDiff: r.setDiff
setDiff: r.setDiff,
isExternal: r.isExternal || false
}))
});
});
@@ -464,6 +513,126 @@ export default {
if (cid == null) return false;
const c = (this.tournamentClasses || []).find(x => x.id === cid);
return Boolean(c && c.isDoubles);
},
openPlayerDialog(entry) {
console.log('[openPlayerDialog] entry:', entry);
// Für Doppel-Paarungen können wir keine Details anzeigen
if (entry.displayName) {
console.log('[openPlayerDialog] Doppel-Paarung, keine Details');
return;
}
if (!this.clubId) {
console.warn('[openPlayerDialog] clubId fehlt');
return;
}
const memberId = entry.member?.id;
if (!memberId) {
console.warn('[openPlayerDialog] Keine Member-ID:', {
hasMember: !!entry.member,
memberId: entry.member?.id
});
return;
}
// Prüfe zuerst in externalParticipants, ob es ein externer Teilnehmer ist
const externalParticipant = this.externalParticipants.find(p => p.id === memberId);
if (externalParticipant) {
// Externer Teilnehmer gefunden
console.log('[openPlayerDialog] Externer Teilnehmer, ID:', memberId);
this.selectedPlayerId = memberId;
this.selectedPlayerIsExternal = true;
this.selectedPlayerName = this.getEntryPlayerName(entry);
this.showPlayerDialog = true;
return;
}
// Prüfe in participants, ob es ein interner Teilnehmer ist
const participant = this.participants.find(p => {
// Prüfe verschiedene mögliche ID-Felder
return (p.member && p.member.id === memberId) ||
p.id === memberId ||
p.clubMemberId === memberId;
});
if (participant) {
// Interner Teilnehmer gefunden
const actualMemberId = participant.member?.id || participant.clubMemberId || memberId;
console.log('[openPlayerDialog] Interner Teilnehmer, Member ID:', actualMemberId);
this.selectedPlayerId = actualMemberId;
this.selectedPlayerIsExternal = false;
this.selectedPlayerName = this.getEntryPlayerName(entry);
this.showPlayerDialog = true;
} else {
// Weder in participants noch in externalParticipants gefunden
// Fallback: Versuche nochmal in externalParticipants mit firstName/lastName
const entryName = this.getEntryPlayerName(entry);
const nameParts = entryName.split(' ').filter(p => p);
if (nameParts.length >= 2) {
const firstName = nameParts[0];
const lastName = nameParts.slice(1).join(' ');
const extByName = this.externalParticipants.find(p =>
p.firstName === firstName && p.lastName === lastName
);
if (extByName) {
console.log('[openPlayerDialog] Externer Teilnehmer (nach Name), ID:', extByName.id);
this.selectedPlayerId = extByName.id;
this.selectedPlayerIsExternal = true;
this.selectedPlayerName = entryName;
this.showPlayerDialog = true;
return;
}
}
console.warn('[openPlayerDialog] Teilnehmer nicht gefunden, versuche als intern:', {
memberId,
entryName
});
// Letzter Fallback: Annahme, dass es ein interner Member ist
this.selectedPlayerId = memberId;
this.selectedPlayerIsExternal = false;
this.selectedPlayerName = this.getEntryPlayerName(entry);
this.showPlayerDialog = true;
}
},
openPlayerDialogFromRanking(ranking) {
console.log('[openPlayerDialogFromRanking] ranking:', ranking);
if (!this.clubId) {
console.warn('[openPlayerDialogFromRanking] clubId nicht verfügbar');
return;
}
// Prüfe, ob es ein interner oder externer Teilnehmer ist
// ranking.id ist die TournamentMember.id oder ExternalTournamentParticipant.id
if (ranking.isExternal === true) {
// Externer Teilnehmer: ranking.id ist die ExternalTournamentParticipant.id
console.log('[openPlayerDialogFromRanking] Externer Teilnehmer, ID:', ranking.id);
this.selectedPlayerId = ranking.id;
this.selectedPlayerIsExternal = true;
this.selectedPlayerName = ranking.name;
this.showPlayerDialog = true;
} else {
// Interner Teilnehmer: Finde den Participant, um die clubMemberId zu bekommen
const participant = this.participants.find(p => p.id === ranking.id);
console.log('[openPlayerDialogFromRanking] Interner Teilnehmer, participant:', participant);
if (participant && participant.member && participant.member.id) {
console.log('[openPlayerDialogFromRanking] Öffne Dialog für Member ID:', participant.member.id);
this.selectedPlayerId = participant.member.id;
this.selectedPlayerIsExternal = false;
this.selectedPlayerName = ranking.name;
this.showPlayerDialog = true;
} else if (participant && participant.clubMemberId) {
// Fallback: Verwende clubMemberId direkt
console.log('[openPlayerDialogFromRanking] Öffne Dialog für clubMemberId:', participant.clubMemberId);
this.selectedPlayerId = participant.clubMemberId;
this.selectedPlayerIsExternal = false;
this.selectedPlayerName = ranking.name;
this.showPlayerDialog = true;
} else {
console.warn('[openPlayerDialogFromRanking] Teilnehmer nicht gefunden für ranking.id:', ranking.id);
}
}
}
}
};
@@ -549,6 +718,16 @@ th {
padding: 2rem;
color: #666;
}
.player-name-clickable {
cursor: pointer;
color: #1976d2;
text-decoration: underline;
}
.player-name-clickable:hover {
color: #1565c0;
}
</style>
/* Spaltenbreite für Platz: 4em */
table thead th:first-child,

View File

@@ -649,6 +649,9 @@
"createMatches": "Spiele erstellen",
"startKORound": "K.o.-Runde starten",
"deleteKORound": "K.o.-Runde",
"address": "Adresse",
"showPlayerDetails": "Spielerdetails anzeigen",
"noPlayerDataAvailable": "Keine Spielerdaten verfügbar",
"koRound": "K.-o.-Runde",
"errorUpdatingTournament": "Fehler beim Aktualisieren des Turniers.",
"pleaseEnterDate": "Bitte geben Sie ein Datum ein!",

View File

@@ -225,6 +225,7 @@
:participants="participants"
:external-participants="externalParticipants"
:pairings="pairings"
:club-id="currentClub"
@update:selectedViewClass="selectedViewClass = $event"
/>
</div>
@@ -491,7 +492,8 @@ export default {
pointsLost: Math.abs(p.pointsLost || 0),
pointRatio: p.pointRatio || 0,
matchesWon: p.matchesWon || 0,
matchesLost: p.matchesLost || 0
matchesLost: p.matchesLost || 0,
isExternal: p.isExternal || false
}));
});
return rankings;