Fügt Unterstützung für myTischtennis-Integration hinzu. Aktualisiert die Mitglieder-Controller und -Routen, um die Aktualisierung von TTR/QTTR-Werten zu ermöglichen. Ergänzt die Benutzeroberfläche in MembersView.vue zur Aktualisierung der Bewertungen und fügt neue Routen für die myTischtennis-Daten hinzu. Aktualisiert die Datenmodelle, um die neuen Felder für TTR und QTTR zu integrieren.

This commit is contained in:
Torsten Schulz (local)
2025-10-01 12:09:55 +02:00
parent 75d304ec6d
commit 4ac71d967f
51 changed files with 2536 additions and 466 deletions

View File

@@ -67,6 +67,16 @@
</a>
</div>
</nav>
<nav class="nav-menu">
<div class="nav-section">
<h4 class="nav-title">Einstellungen</h4>
<a href="/mytischtennis-account" class="nav-link">
<span class="nav-icon">🔗</span>
myTischtennis-Account
</a>
</div>
</nav>
<div class="sidebar-footer">
<button @click="logout()" class="btn-secondary logout-btn">

View File

@@ -0,0 +1,293 @@
<template>
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h3>{{ account ? 'myTischtennis-Account bearbeiten' : 'myTischtennis-Account verknüpfen' }}</h3>
</div>
<div class="modal-body">
<div class="form-group">
<label for="mtt-email">myTischtennis-E-Mail:</label>
<input
type="email"
id="mtt-email"
v-model="formData.email"
placeholder="Ihre myTischtennis-E-Mail-Adresse"
required
/>
</div>
<div class="form-group">
<label for="mtt-password">myTischtennis-Passwort:</label>
<input
type="password"
id="mtt-password"
v-model="formData.password"
:placeholder="account && account.savePassword ? 'Leer lassen um beizubehalten' : 'Ihr myTischtennis-Passwort'"
/>
</div>
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
v-model="formData.savePassword"
/>
<span>myTischtennis-Passwort speichern</span>
</label>
<p class="hint">
Wenn aktiviert, wird Ihr myTischtennis-Passwort verschlüsselt gespeichert,
sodass automatische Synchronisationen möglich sind.
</p>
</div>
<div class="form-group" v-if="formData.password">
<label for="app-password">Ihr App-Passwort zur Bestätigung:</label>
<input
type="password"
id="app-password"
v-model="formData.userPassword"
placeholder="Ihr Passwort für diese App"
required
/>
<p class="hint">
Aus Sicherheitsgründen benötigen wir Ihr App-Passwort,
um das myTischtennis-Passwort zu speichern.
</p>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="$emit('close')" :disabled="saving">
Abbrechen
</button>
<button class="btn-primary" @click="saveAccount" :disabled="!canSave || saving">
{{ saving ? 'Speichere...' : 'Speichern' }}
</button>
</div>
</div>
</div>
</template>
<script>
import apiClient from '../apiClient.js';
export default {
name: 'MyTischtennisDialog',
props: {
account: {
type: Object,
default: null
}
},
data() {
return {
formData: {
email: this.account?.email || '',
password: '',
savePassword: this.account?.savePassword || false,
userPassword: ''
},
saving: false,
error: null
};
},
computed: {
canSave() {
// E-Mail ist erforderlich
if (!this.formData.email.trim()) {
return false;
}
// Wenn ein Passwort eingegeben wurde, muss auch das App-Passwort eingegeben werden
if (this.formData.password && !this.formData.userPassword) {
return false;
}
return true;
}
},
methods: {
async saveAccount() {
if (!this.canSave) return;
this.error = null;
this.saving = true;
try {
const payload = {
email: this.formData.email,
savePassword: this.formData.savePassword
};
// Nur password und userPassword hinzufügen, wenn ein Passwort eingegeben wurde
if (this.formData.password) {
payload.password = this.formData.password;
payload.userPassword = this.formData.userPassword;
}
await apiClient.post('/mytischtennis/account', payload);
this.$emit('saved');
} catch (error) {
console.error('Fehler beim Speichern:', error);
this.error = error.response?.data?.message || 'Fehler beim Speichern des Accounts';
} finally {
this.saving = false;
}
}
}
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;
}
.modal-header h3 {
margin: 0;
color: #495057;
}
.modal-body {
padding: 1.5rem;
flex: 1;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #495057;
}
.form-group input[type="text"],
.form-group input[type="email"],
.form-group input[type="password"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
}
.form-group input[type="text"]:focus,
.form-group input[type="email"]:focus,
.form-group input[type="password"]:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: auto;
margin: 0;
cursor: pointer;
}
.hint {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #6c757d;
font-style: italic;
}
.error-message {
padding: 0.75rem;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
color: #721c24;
margin-top: 1rem;
}
.btn-primary, .btn-secondary {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0056b3;
}
.btn-primary:disabled {
background-color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #545b62;
}
.btn-secondary:disabled {
cursor: not-allowed;
opacity: 0.6;
}
</style>

View File

@@ -13,6 +13,7 @@ import TournamentsView from './views/TournamentsView.vue';
import TrainingStatsView from './views/TrainingStatsView.vue';
import PredefinedActivities from './views/PredefinedActivities.vue';
import OfficialTournaments from './views/OfficialTournaments.vue';
import MyTischtennisAccount from './views/MyTischtennisAccount.vue';
import Impressum from './views/Impressum.vue';
import Datenschutz from './views/Datenschutz.vue';
@@ -31,6 +32,7 @@ const routes = [
{ path: '/training-stats', component: TrainingStatsView },
{ path: '/predefined-activities', component: PredefinedActivities },
{ path: '/official-tournaments', component: OfficialTournaments },
{ path: '/mytischtennis-account', component: MyTischtennisAccount },
{ path: '/impressum', component: Impressum },
{ path: '/datenschutz', component: Datenschutz },
];

View File

@@ -1,8 +1,12 @@
<template>
<div>
<h2>Mitglieder</h2>
<div>
<button @click="createPhoneList">Telefonliste generieren</button>Es werden nur aktive Mitglieder ausgegeben
<div class="action-buttons">
<button @click="createPhoneList">Telefonliste generieren</button>
<span class="info-text">Es werden nur aktive Mitglieder ausgegeben</span>
<button @click="updateRatingsFromMyTischtennis" class="btn-update-ratings" :disabled="isUpdatingRatings">
{{ isUpdatingRatings ? 'Aktualisiere...' : 'TTR/QTTR von myTischtennis aktualisieren' }}
</button>
</div>
<div class="newmember">
<div class="toggle-new-member">
@@ -56,6 +60,7 @@
<th>Bild (Inet?)</th>
<th>Testm.</th>
<th>Name, Vorname</th>
<th>TTR / QTTR</th>
<th>Adresse</th>
<th>Geburtsdatum</th>
<th>Telefon-Nr.</th>
@@ -80,6 +85,14 @@
<span v-if="!member.active && showInactiveMembers" class="inactive-badge">inaktiv</span>
</span>
</td>
<td class="rating-cell">
<span v-if="member.ttr || member.qttr">
<span v-if="member.ttr" class="ttr-value">{{ member.ttr }}</span>
<span v-if="member.ttr && member.qttr" class="rating-separator">/</span>
<span v-if="member.qttr" class="qttr-value">{{ member.qttr }}</span>
</span>
<span v-else class="no-rating">-</span>
</td>
<td>{{ member.street }}, {{ member.city }}</td>
<td>{{ getFormattedBirthdate(member.birthDate) }}</td>
<td>{{ member.phone }}</td>
@@ -152,7 +165,8 @@ export default {
selectedImageUrl: null,
testMembership: false,
showInactiveMembers: false,
newPicsInInternetAllowed: false
newPicsInInternetAllowed: false,
isUpdatingRatings: false
}
},
async mounted() {
@@ -371,6 +385,29 @@ export default {
if (v === 'female') return '♀';
if (v === 'diverse') return '⚧';
return '';
},
async updateRatingsFromMyTischtennis() {
if (!confirm('TTR/QTTR-Werte von myTischtennis aktualisieren?')) {
return;
}
this.isUpdatingRatings = true;
try {
const response = await apiClient.post(`/clubmembers/update-ratings/${this.currentClub}`);
if (response.data.message) {
alert(response.data.message);
}
// Mitglieder neu laden um aktualisierte Werte anzuzeigen
await this.loadMembers();
} catch (error) {
console.error('Fehler beim Aktualisieren der Ratings:', error);
const message = error.response?.data?.error || error.response?.data?.message || 'Fehler beim Aktualisieren der TTR/QTTR-Werte';
alert(message);
} finally {
this.isUpdatingRatings = false;
}
}
}
}
@@ -489,4 +526,61 @@ table td {
.row-inactive { opacity: .6; }
.is-inactive { text-decoration: line-through; }
.inactive-badge { margin-left: .5rem; font-size: .85em; color: #666; text-transform: lowercase; }
.rating-cell {
font-family: 'Courier New', monospace;
font-size: 0.95em;
}
.ttr-value {
font-weight: 600;
color: #1a73e8;
}
.qttr-value {
font-weight: 600;
color: #d81b60;
}
.rating-separator {
color: #999;
margin: 0 0.25rem;
}
.no-rating {
color: #999;
}
.action-buttons {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.info-text {
font-size: 0.9em;
color: #666;
}
.btn-update-ratings {
background-color: #28a745;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s ease;
}
.btn-update-ratings:hover:not(:disabled) {
background-color: #218838;
}
.btn-update-ratings:disabled {
background-color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,423 @@
<template>
<div class="page-container">
<h1>myTischtennis-Account</h1>
<div class="account-container">
<div v-if="loading" class="loading">Lade...</div>
<div v-else-if="account" class="account-info">
<div class="info-section">
<h2>Verknüpfter Account</h2>
<div class="info-row">
<label>E-Mail:</label>
<span>{{ account.email }}</span>
</div>
<div class="info-row">
<label>Passwort gespeichert:</label>
<span>{{ account.savePassword ? 'Ja' : 'Nein' }}</span>
</div>
<div class="info-row" v-if="account.clubId">
<label>Verein (myTischtennis):</label>
<span>{{ account.clubName }} ({{ account.clubId }}{{ account.fedNickname ? ' - ' + account.fedNickname : '' }})</span>
</div>
<div class="info-row" v-if="account.lastLoginSuccess">
<label>Letzter erfolgreicher Login:</label>
<span>{{ formatDate(account.lastLoginSuccess) }}</span>
</div>
<div class="info-row" v-if="account.lastLoginAttempt">
<label>Letzter Login-Versuch:</label>
<span>{{ formatDate(account.lastLoginAttempt) }}</span>
</div>
<div class="button-group">
<button class="btn-primary" @click="openEditDialog">Account bearbeiten</button>
<button class="btn-secondary" @click="testConnection">Verbindung testen</button>
<button class="btn-secondary" @click="testLoginFlow">Test: Login-Flow</button>
<button class="btn-danger" @click="deleteAccount">Account trennen</button>
</div>
</div>
<!-- Test-Ausgabe -->
<div v-if="testResult" class="test-result" :class="testResult.type">
<h3>Test-Ergebnis:</h3>
<pre>{{ testResult.data }}</pre>
</div>
</div>
<div v-else class="no-account">
<p>Kein myTischtennis-Account verknüpft.</p>
<button class="btn-primary" @click="openEditDialog">Account verknüpfen</button>
</div>
<div class="info-box">
<h3>Über myTischtennis</h3>
<p>Durch die Verknüpfung Ihres myTischtennis-Accounts können Sie:</p>
<ul>
<li>Automatisch Turnierdaten importieren</li>
<li>Spielerergebnisse synchronisieren</li>
<li>Wettkampfdaten direkt abrufen</li>
</ul>
<p><strong>Hinweis:</strong> Das Speichern des Passworts ist optional. Wenn Sie es nicht speichern, werden Sie bei jeder Synchronisation nach dem Passwort gefragt.</p>
</div>
</div>
<!-- Edit Dialog -->
<MyTischtennisDialog
v-if="showDialog"
:account="account"
@close="closeDialog"
@saved="onAccountSaved"
/>
</div>
</template>
<script>
import apiClient from '../apiClient.js';
import MyTischtennisDialog from '../components/MyTischtennisDialog.vue';
export default {
name: 'MyTischtennisAccount',
components: {
MyTischtennisDialog
},
data() {
return {
loading: true,
account: null,
showDialog: false,
testResult: null
};
},
mounted() {
this.loadAccount();
},
methods: {
async loadAccount() {
try {
this.loading = true;
const response = await apiClient.get('/mytischtennis/account');
this.account = response.data.account;
} catch (error) {
console.error('Fehler beim Laden des Accounts:', error);
this.$store.dispatch('showMessage', {
text: 'Fehler beim Laden des myTischtennis-Accounts',
type: 'error'
});
} finally {
this.loading = false;
}
},
openEditDialog() {
this.showDialog = true;
},
closeDialog() {
this.showDialog = false;
},
async onAccountSaved() {
this.closeDialog();
await this.loadAccount();
this.$store.dispatch('showMessage', {
text: 'myTischtennis-Account erfolgreich gespeichert',
type: 'success'
});
},
async testConnection() {
this.testResult = null;
try {
await apiClient.post('/mytischtennis/verify');
this.$store.dispatch('showMessage', {
text: 'Verbindung erfolgreich! Login funktioniert.',
type: 'success'
});
await this.loadAccount(); // Aktualisiere lastLoginSuccess
} catch (error) {
const message = error.response?.data?.message || 'Verbindung fehlgeschlagen';
if (error.response?.status === 400 && message.includes('Kein Passwort gespeichert')) {
// Passwort-Dialog öffnen
this.showDialog = true;
}
this.$store.dispatch('showMessage', {
text: message,
type: 'error'
});
}
},
async testLoginFlow() {
this.testResult = null;
try {
// 1. Verify Login
console.log('Testing login...');
const verifyResponse = await apiClient.post('/mytischtennis/verify');
console.log('Login successful:', verifyResponse.data);
// 2. Get Session
console.log('Fetching session...');
const sessionResponse = await apiClient.get('/mytischtennis/session');
console.log('Session data:', sessionResponse.data);
// 3. Check Status
console.log('Checking status...');
const statusResponse = await apiClient.get('/mytischtennis/status');
console.log('Status:', statusResponse.data);
this.testResult = {
type: 'success',
data: {
message: 'Alle Tests erfolgreich!',
login: {
accessToken: verifyResponse.data.accessToken ? '✓ vorhanden' : '✗ fehlt',
expiresAt: verifyResponse.data.expiresAt,
clubId: verifyResponse.data.clubId || '✗ nicht gefunden',
clubName: verifyResponse.data.clubName || '✗ nicht gefunden'
},
session: {
accessToken: sessionResponse.data.session?.accessToken ? '✓ vorhanden' : '✗ fehlt',
refreshToken: sessionResponse.data.session?.refreshToken ? '✓ vorhanden' : '✗ fehlt',
cookie: sessionResponse.data.session?.cookie ? '✓ vorhanden' : '✗ fehlt',
userData: sessionResponse.data.session?.userData ? '✓ vorhanden' : '✗ fehlt',
expiresAt: sessionResponse.data.session?.expiresAt
},
status: statusResponse.data
}
};
this.$store.dispatch('showMessage', {
text: 'Test erfolgreich! Details siehe unten.',
type: 'success'
});
} catch (error) {
console.error('Test failed:', error);
this.testResult = {
type: 'error',
data: {
message: 'Test fehlgeschlagen',
error: error.response?.data?.message || error.message,
status: error.response?.status,
details: error.response?.data
}
};
this.$store.dispatch('showMessage', {
text: `Test fehlgeschlagen: ${error.response?.data?.message || error.message}`,
type: 'error'
});
}
},
async deleteAccount() {
if (!confirm('Möchten Sie die Verknüpfung zum myTischtennis-Account wirklich trennen?')) {
return;
}
try {
await apiClient.delete('/mytischtennis/account');
this.account = null;
this.$store.dispatch('showMessage', {
text: 'myTischtennis-Account erfolgreich getrennt',
type: 'success'
});
} catch (error) {
console.error('Fehler beim Löschen des Accounts:', error);
this.$store.dispatch('showMessage', {
text: 'Fehler beim Trennen des Accounts',
type: 'error'
});
}
},
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
}
};
</script>
<style scoped>
.page-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
color: var(--text-color, #333);
margin-bottom: 2rem;
}
.account-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.account-info, .no-account {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.info-section h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: var(--primary-color, #007bff);
}
.info-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid #eee;
}
.info-row:last-of-type {
border-bottom: none;
}
.info-row label {
font-weight: 600;
color: #555;
}
.info-row span {
color: #333;
}
.button-group {
display: flex;
gap: 1rem;
margin-top: 2rem;
flex-wrap: wrap;
}
.no-account {
text-align: center;
}
.no-account p {
margin-bottom: 1.5rem;
color: #666;
}
.info-box {
background: #f8f9fa;
border-left: 4px solid var(--primary-color, #007bff);
padding: 1.5rem;
border-radius: 4px;
}
.info-box h3 {
margin-top: 0;
color: var(--primary-color, #007bff);
}
.info-box ul {
margin: 1rem 0;
padding-left: 1.5rem;
}
.info-box li {
margin: 0.5rem 0;
}
.btn-primary, .btn-secondary, .btn-danger {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.test-result {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-top: 1rem;
}
.test-result.success {
border-left: 4px solid #28a745;
}
.test-result.error {
border-left: 4px solid #dc3545;
}
.test-result h3 {
margin-top: 0;
margin-bottom: 1rem;
color: #333;
}
.test-result pre {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 1rem;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>