Erweitert den MatchReportApiDialog um neue Funktionen zur Verwaltung von Spielberichten. Implementiert eine verbesserte Logik zur Berechnung der Gesamtpunkte und Sätze sowie zur Validierung von Eingaben. Fügt visuelle Hinweise für den Abschlussstatus und Warnungen bei fehlerhaften Eingaben hinzu. Optimiert die Benutzeroberfläche mit neuen CSS-Stilen für eine bessere Benutzererfahrung.
@@ -2,8 +2,15 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<title>Trainingstagebuch – Vereinsverwaltung, Trainingsplanung & Turniere</title>
|
||||
<meta name="description" content="Das TrainingsTagebuch hilft Vereinen und Trainer:innen, Mitglieder zu verwalten, Trainings zu dokumentieren, Spielpläne zu organisieren und Statistiken auszuwerten – alles in einer modernen Web‑App." />
|
||||
<link rel="canonical" href="https://tt-tagebuch.de/" />
|
||||
@@ -14,13 +21,13 @@
|
||||
<meta property="og:title" content="Trainingstagebuch – Vereinsverwaltung, Trainingsplanung & Turniere" />
|
||||
<meta property="og:description" content="Mitgliederverwaltung, Trainingstagebuch, Spiel‑ und Turnierorganisation sowie Statistiken – DSGVO‑freundlich und einfach." />
|
||||
<meta property="og:url" content="https://tt-tagebuch.de/" />
|
||||
<meta property="og:image" content="https://tt-tagebuch.de/vite.svg" />
|
||||
<meta property="og:image" content="https://tt-tagebuch.de/android-chrome-512x512.png" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Trainingstagebuch – Vereinsverwaltung, Trainingsplanung & Turniere" />
|
||||
<meta name="twitter:description" content="Mitgliederverwaltung, Trainingstagebuch, Spiel‑ und Turnierorganisation sowie Statistiken – DSGVO‑freundlich und einfach." />
|
||||
<meta name="twitter:image" content="https://tt-tagebuch.de/vite.svg" />
|
||||
<meta name="twitter:image" content="https://tt-tagebuch.de/android-chrome-512x512.png" />
|
||||
|
||||
<!-- JSON-LD: Website + Organization -->
|
||||
<script type="application/ld+json">
|
||||
|
||||
10
frontend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"sortablejs": "^1.15.3",
|
||||
"vue": "^3.2.13",
|
||||
"vue-multiselect": "^3.0.0",
|
||||
@@ -2552,6 +2553,15 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"sortablejs": "^1.15.3",
|
||||
"vue": "^3.2.13",
|
||||
"vue-multiselect": "^3.0.0",
|
||||
|
||||
BIN
frontend/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 534 B |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
19
frontend/public/site.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Trainingstagebuch",
|
||||
"short_name": "TT-Tagebuch",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#2c3e50",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -7,10 +7,44 @@
|
||||
<span>Trainingstagebuch</span>
|
||||
</router-link>
|
||||
</h1>
|
||||
<div v-if="isAuthenticated" class="user-menu">
|
||||
<button @click="toggleUserDropdown" class="user-info">
|
||||
<span class="user-icon">👤</span>
|
||||
<span class="user-email">{{ username }}</span>
|
||||
<span class="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
<div v-if="userDropdownOpen" class="user-dropdown">
|
||||
<router-link to="/mytischtennis-account" class="dropdown-item" @click="userDropdownOpen = false">
|
||||
<span class="dropdown-icon">🔗</span>
|
||||
myTischtennis-Account
|
||||
</router-link>
|
||||
<router-link v-if="canManagePermissions" to="/permissions" class="dropdown-item" @click="userDropdownOpen = false">
|
||||
<span class="dropdown-icon">🔐</span>
|
||||
Berechtigungen
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('members', 'write')" to="/member-transfer-settings" class="dropdown-item" @click="userDropdownOpen = false">
|
||||
<span class="dropdown-icon">📤</span>
|
||||
Mitgliederübertragung
|
||||
</router-link>
|
||||
<router-link v-if="isAdmin" to="/logs" class="dropdown-item" @click="userDropdownOpen = false">
|
||||
<span class="dropdown-icon">📋</span>
|
||||
System-Logs
|
||||
</router-link>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button @click="logout" class="dropdown-item logout-item">
|
||||
<span class="dropdown-icon">🚪</span>
|
||||
Ausloggen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-container">
|
||||
<aside v-if="isAuthenticated" class="sidebar">
|
||||
<aside v-if="isAuthenticated" class="sidebar" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||
<button class="sidebar-toggle" @click="toggleSidebar">
|
||||
<span v-if="sidebarCollapsed">→</span>
|
||||
<span v-else>←</span>
|
||||
</button>
|
||||
<div class="sidebar-content">
|
||||
<div class="club-selector card">
|
||||
<h3 class="card-title">Verein auswählen</h3>
|
||||
@@ -29,76 +63,56 @@
|
||||
<nav v-if="selectedClub" class="nav-menu">
|
||||
<div class="nav-section">
|
||||
<h4 class="nav-title">Verwaltung</h4>
|
||||
<a href="/members" class="nav-link">
|
||||
<router-link v-if="hasPermission('members', 'read')" to="/members" class="nav-link" title="Mitglieder">
|
||||
<span class="nav-icon">👥</span>
|
||||
Mitglieder
|
||||
</a>
|
||||
<a href="/diary" class="nav-link">
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('diary', 'read')" to="/diary" class="nav-link" title="Tagebuch">
|
||||
<span class="nav-icon">📝</span>
|
||||
Tagebuch
|
||||
</a>
|
||||
<a href="/pending-approvals" class="nav-link">
|
||||
</router-link>
|
||||
<router-link v-if="canManageApprovals" to="/pending-approvals" class="nav-link" title="Freigaben">
|
||||
<span class="nav-icon">⏳</span>
|
||||
Freigaben
|
||||
</a>
|
||||
<a href="/training-stats" class="nav-link">
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('statistics', 'read')" to="/training-stats" class="nav-link" title="Trainings-Statistik">
|
||||
<span class="nav-icon">📊</span>
|
||||
Trainings-Statistik
|
||||
</a>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4 class="nav-title">Organisation</h4>
|
||||
<a href="/schedule" class="nav-link">
|
||||
<router-link v-if="hasPermission('schedule', 'read')" to="/schedule" class="nav-link" title="Spielpläne">
|
||||
<span class="nav-icon">📅</span>
|
||||
Spielpläne
|
||||
</a>
|
||||
<a href="/tournaments" class="nav-link">
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('tournaments', 'read')" to="/tournaments" class="nav-link" title="Interne Turniere">
|
||||
<span class="nav-icon">🏆</span>
|
||||
Interne Turniere
|
||||
</a>
|
||||
<a href="/official-tournaments" class="nav-link">
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('tournaments', 'read')" to="/official-tournaments" class="nav-link" title="Offizielle Turniere">
|
||||
<span class="nav-icon">📄</span>
|
||||
Offizielle Turniere
|
||||
</a>
|
||||
<a href="/predefined-activities" class="nav-link">
|
||||
</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>
|
||||
Vordefinierte Aktivitäten
|
||||
</a>
|
||||
<a href="/team-management" class="nav-link">
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('teams', 'read')" to="/team-management" class="nav-link" title="Team-Verwaltung">
|
||||
<span class="nav-icon">👥</span>
|
||||
Team-Verwaltung
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<nav class="sidebar-footer">
|
||||
<div class="nav-section">
|
||||
<h4 class="nav-title">Einstellungen</h4>
|
||||
<router-link to="/club-settings" class="nav-link">
|
||||
<span class="nav-icon">🏟️</span>
|
||||
Vereins-Einstellungen
|
||||
</router-link>
|
||||
<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">
|
||||
<span class="nav-icon">🚪</span>
|
||||
Ausloggen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<div v-else class="auth-nav">
|
||||
<div class="auth-links">
|
||||
<a href="/login" class="btn-primary">Einloggen</a>
|
||||
<a href="/register" class="btn-secondary">Registrieren</a>
|
||||
<router-link to="/login" class="btn-primary">Einloggen</router-link>
|
||||
<router-link to="/register" class="btn-secondary">Registrieren</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +132,27 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -125,21 +160,61 @@ import { mapGetters, mapActions } from 'vuex';
|
||||
import apiClient from './apiClient.js';
|
||||
import logoUrl from './assets/logo.png';
|
||||
import DialogManager from './components/DialogManager.vue';
|
||||
|
||||
import InfoDialog from './components/InfoDialog.vue';
|
||||
import ConfirmDialog from './components/ConfirmDialog.vue';
|
||||
import { buildInfoConfig, buildConfirmConfig } from './utils/dialogUtils.js';
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
DialogManager
|
||||
},
|
||||
,
|
||||
InfoDialog,
|
||||
ConfirmDialog},
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
infoDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
},
|
||||
confirmDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
},
|
||||
selectedClub: null,
|
||||
sessionInterval: null,
|
||||
logoUrl,
|
||||
userDropdownOpen: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isAuthenticated', 'currentClub', 'clubs']),
|
||||
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'sidebarCollapsed', 'username', 'hasPermission', 'isClubOwner', 'userRole']),
|
||||
canManageApprovals() {
|
||||
// Nur anzeigen, wenn Permissions geladen sind UND Berechtigung vorhanden
|
||||
if (!this.currentClub) return false;
|
||||
|
||||
// Owner oder Admin können Freigaben verwalten
|
||||
return this.isClubOwner || this.userRole === 'admin' || this.hasPermission('approvals', 'read');
|
||||
},
|
||||
isAdmin() {
|
||||
// Nur anzeigen, wenn Permissions geladen sind UND Admin-Rechte vorhanden
|
||||
if (!this.currentClub) return false;
|
||||
return this.isClubOwner || this.userRole === 'admin';
|
||||
},
|
||||
canManagePermissions() {
|
||||
// Nur anzeigen, wenn Permissions geladen sind UND Berechtigung vorhanden
|
||||
if (!this.currentClub) return false;
|
||||
|
||||
// Owner oder Admin können Berechtigungen verwalten
|
||||
return this.isClubOwner || this.userRole === 'admin' || this.hasPermission('permissions', 'read');
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedClub(newVal) {
|
||||
@@ -148,9 +223,8 @@ export default {
|
||||
currentClub(newVal) {
|
||||
if (newVal === 'new') {
|
||||
this.$router.push('/createclub');
|
||||
} else if (newVal) {
|
||||
this.$router.push('/training-stats');
|
||||
}
|
||||
// Removed automatic redirect to training-stats to allow manual navigation
|
||||
},
|
||||
isAuthenticated(newVal) {
|
||||
if (newVal) {
|
||||
@@ -168,7 +242,43 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['setCurrentClub', 'setClubs', 'logout']),
|
||||
toggleUserDropdown(event) {
|
||||
event.stopPropagation();
|
||||
this.userDropdownOpen = !this.userDropdownOpen;
|
||||
},
|
||||
handleClickOutside(event) {
|
||||
const userMenu = event.target.closest('.user-menu');
|
||||
if (!userMenu && this.userDropdownOpen) {
|
||||
this.userDropdownOpen = false;
|
||||
}
|
||||
},
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = buildInfoConfig({ title, message, details, type });
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info', options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = buildConfirmConfig({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
...mapActions(['setCurrentClub', 'setClubs', 'logout', 'toggleSidebar']),
|
||||
|
||||
async loadUserData() {
|
||||
try {
|
||||
@@ -178,7 +288,8 @@ export default {
|
||||
this.selectedClub = this.currentClub;
|
||||
}
|
||||
this.checkSession();
|
||||
this.sessionInterval = setInterval(this.checkSession, 5000);
|
||||
// Session-Check alle 30 Sekunden
|
||||
this.sessionInterval = setInterval(this.checkSession, 30000);
|
||||
} catch (error) {
|
||||
this.setClubs([]);
|
||||
this.selectedClub = null;
|
||||
@@ -204,13 +315,16 @@ export default {
|
||||
},
|
||||
|
||||
handleLogout() {
|
||||
alert('Deine Sitzung ist abgelaufen. Du wirst abgemeldet.');
|
||||
this.showInfo('Hinweis', 'Deine Sitzung ist abgelaufen. Du wirst abgemeldet.', '', 'warning');
|
||||
this.logout();
|
||||
clearInterval(this.sessionInterval);
|
||||
this.$router.push('/login');
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
// Click-outside handler für User-Dropdown
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
|
||||
// Nur Daten laden, wenn der Benutzer authentifiziert ist
|
||||
if (this.isAuthenticated) {
|
||||
try {
|
||||
@@ -220,7 +334,8 @@ export default {
|
||||
this.selectedClub = this.currentClub;
|
||||
}
|
||||
this.checkSession();
|
||||
this.sessionInterval = setInterval(this.checkSession, 5000);
|
||||
// Session-Check alle 30 Sekunden
|
||||
this.sessionInterval = setInterval(this.checkSession, 30000);
|
||||
} catch (error) {
|
||||
this.setClubs([]);
|
||||
this.selectedClub = null;
|
||||
@@ -229,6 +344,7 @@ export default {
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearInterval(this.sessionInterval);
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -250,6 +366,10 @@ export default {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -269,6 +389,105 @@ export default {
|
||||
/* Schriftgröße bleibt wie in der main.scss definiert */
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.user-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.25rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
z-index: 10000;
|
||||
animation: dropdownFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes dropdownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.logout-item {
|
||||
color: #dc3545;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logout-item:hover {
|
||||
background-color: #fff5f5;
|
||||
}
|
||||
|
||||
.home-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -464,6 +683,7 @@ export default {
|
||||
overflow-y: auto;
|
||||
background: var(--background-light);
|
||||
min-height: 0;
|
||||
padding-bottom: 32px; /* Platz für Statusleiste (24px + 8px padding) */
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
@@ -532,14 +752,83 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
/* Toggle-Button für Sidebar (nur auf mobil sichtbar) */
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 3rem;
|
||||
left: 0;
|
||||
height: calc(100vh - 3rem);
|
||||
z-index: 999;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.sidebar-collapsed {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.sidebar:not(.sidebar-collapsed) {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.sidebar-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Im kollabierten Zustand: nur Icons zeigen */
|
||||
.sidebar-collapsed .nav-link span:not(.nav-icon),
|
||||
.sidebar-collapsed .nav-link text,
|
||||
.sidebar-collapsed .nav-title,
|
||||
.sidebar-collapsed .card-title,
|
||||
.sidebar-collapsed .logout-btn span:not(.nav-icon),
|
||||
.sidebar-collapsed .club-selector,
|
||||
.sidebar-collapsed .select-group,
|
||||
.sidebar-collapsed .btn-primary {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Alle Text-Inhalte in nav-links verstecken */
|
||||
.sidebar-collapsed .nav-link {
|
||||
justify-content: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .nav-icon {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .logout-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar-footer {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -554,13 +843,27 @@ export default {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
display: none; /* Nur Icon auf mobile */
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
margin-left: 60px;
|
||||
overflow-y: auto;
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar:not(.sidebar-collapsed) ~ .main-content {
|
||||
margin-left: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import store from './store';
|
||||
|
||||
export const backendBaseUrl = import.meta.env.VITE_BACKEND || 'http://localhost:3005';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: `${import.meta.env.VITE_BACKEND}/api`,
|
||||
baseURL: `${backendBaseUrl}/api`,
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use(config => {
|
||||
|
||||
@@ -369,8 +369,8 @@ th, td {
|
||||
th {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
BIN
frontend/src/assets/favicon_io/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/src/assets/favicon_io/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
frontend/src/assets/favicon_io/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/src/assets/favicon_io/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 534 B |
BIN
frontend/src/assets/favicon_io/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/src/assets/favicon_io/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
frontend/src/assets/favicon_io/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
221
frontend/src/components/AccidentFormDialog.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
title="Unfall melden"
|
||||
size="medium"
|
||||
@close="handleClose"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="accident-form">
|
||||
<div class="form-group">
|
||||
<label for="memberId">Mitglied:</label>
|
||||
<select id="memberId" v-model="localAccident.memberId" class="form-select">
|
||||
<option value="">Bitte wählen</option>
|
||||
<option
|
||||
v-for="member in availableMembers"
|
||||
:key="member.id"
|
||||
:value="member.id"
|
||||
>
|
||||
{{ member.firstName }} {{ member.lastName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="accident">Unfall:</label>
|
||||
<textarea
|
||||
id="accident"
|
||||
v-model="localAccident.accident"
|
||||
required
|
||||
class="form-textarea"
|
||||
placeholder="Beschreibung des Unfalls..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="accidents.length > 0" class="accidents-list">
|
||||
<h4>Gemeldete Unfälle</h4>
|
||||
<ul>
|
||||
<li v-for="accident in accidents" :key="accident.id" class="accident-item">
|
||||
<strong>{{ accident.firstName }} {{ accident.lastName }}:</strong> {{ accident.accident }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" @click="handleClose" class="btn-secondary">Schließen</button>
|
||||
<button type="button" @click="handleSubmit" class="btn-primary" :disabled="!isValid">Eintragen</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'AccidentFormDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
accident: {
|
||||
type: Object,
|
||||
default: () => ({ memberId: '', accident: '' })
|
||||
},
|
||||
members: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
participants: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
accidents: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'submit', 'update:accident'],
|
||||
data() {
|
||||
return {
|
||||
localAccident: { ...this.accident }
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
availableMembers() {
|
||||
return this.members.filter(m => this.participants.indexOf(m.id) >= 0);
|
||||
},
|
||||
isValid() {
|
||||
return this.localAccident.memberId && this.localAccident.accident && this.localAccident.accident.trim() !== '';
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
accident: {
|
||||
handler(newVal) {
|
||||
this.localAccident = { ...newVal };
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
localAccident: {
|
||||
handler(newVal) {
|
||||
this.$emit('update:accident', newVal);
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
handleSubmit() {
|
||||
if (this.isValid) {
|
||||
this.$emit('submit', this.localAccident);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.accident-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-select:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-light);
|
||||
}
|
||||
|
||||
.accidents-list {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.accidents-list h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.accidents-list ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.accident-item {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
</style>
|
||||
|
||||
379
frontend/src/components/BaseDialog.vue
Normal file
@@ -0,0 +1,379 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
:class="['base-dialog', { 'modal-dialog': isModal, 'non-modal-dialog': !isModal }]"
|
||||
@click.self="handleOverlayClick"
|
||||
>
|
||||
<div
|
||||
:class="['dialog-container', sizeClass]"
|
||||
:style="dialogStyle"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="dialog-header"
|
||||
:class="{ draggable: isDraggable }"
|
||||
@mousedown="startDrag"
|
||||
>
|
||||
<h3 class="dialog-title">{{ title }}</h3>
|
||||
<slot name="header-actions"></slot>
|
||||
<div class="dialog-controls">
|
||||
<button
|
||||
v-if="minimizable"
|
||||
@click="$emit('minimize')"
|
||||
class="control-btn minimize-btn"
|
||||
title="Minimieren"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
v-if="closable"
|
||||
@click="handleClose"
|
||||
class="control-btn close-btn"
|
||||
title="Schließen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="dialog-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Footer (optional) -->
|
||||
<div v-if="$slots.footer" class="dialog-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'BaseDialog',
|
||||
props: {
|
||||
// Sichtbarkeit
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Dialog-Typ
|
||||
isModal: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// Titel
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Größe: 'small', 'medium', 'large', 'fullscreen'
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
validator: (value) => ['small', 'medium', 'large', 'fullscreen'].includes(value)
|
||||
},
|
||||
|
||||
// Position für nicht-modale Dialoge
|
||||
position: {
|
||||
type: Object,
|
||||
default: () => ({ x: 100, y: 100 })
|
||||
},
|
||||
|
||||
// z-Index
|
||||
zIndex: {
|
||||
type: Number,
|
||||
default: 1000
|
||||
},
|
||||
|
||||
// Funktionalität
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
minimizable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
draggable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// Schließen bei Overlay-Klick (nur bei modalen Dialogen)
|
||||
closeOnOverlay: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
localPosition: { ...this.position },
|
||||
isDragging: false,
|
||||
dragStartX: 0,
|
||||
dragStartY: 0
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isVisible() {
|
||||
return this.modelValue;
|
||||
},
|
||||
|
||||
sizeClass() {
|
||||
return `dialog-${this.size}`;
|
||||
},
|
||||
|
||||
dialogStyle() {
|
||||
const style = {
|
||||
zIndex: this.zIndex
|
||||
};
|
||||
|
||||
if (!this.isModal) {
|
||||
style.left = `${this.localPosition.x}px`;
|
||||
style.top = `${this.localPosition.y}px`;
|
||||
}
|
||||
|
||||
return style;
|
||||
},
|
||||
|
||||
isDraggable() {
|
||||
return this.draggable && !this.isModal;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
handleOverlayClick() {
|
||||
if (this.isModal && this.closeOnOverlay) {
|
||||
this.handleClose();
|
||||
}
|
||||
},
|
||||
|
||||
handleMouseDown() {
|
||||
if (!this.isModal) {
|
||||
this.$emit('focus');
|
||||
}
|
||||
},
|
||||
|
||||
startDrag(event) {
|
||||
if (!this.isDraggable) return;
|
||||
|
||||
this.isDragging = true;
|
||||
this.dragStartX = event.clientX - this.localPosition.x;
|
||||
this.dragStartY = event.clientY - this.localPosition.y;
|
||||
|
||||
document.addEventListener('mousemove', this.onDrag);
|
||||
document.addEventListener('mouseup', this.stopDrag);
|
||||
|
||||
event.preventDefault();
|
||||
},
|
||||
|
||||
onDrag(event) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
this.localPosition.x = event.clientX - this.dragStartX;
|
||||
this.localPosition.y = event.clientY - this.dragStartY;
|
||||
|
||||
this.$emit('update:position', { ...this.localPosition });
|
||||
},
|
||||
|
||||
stopDrag() {
|
||||
this.isDragging = false;
|
||||
document.removeEventListener('mousemove', this.onDrag);
|
||||
document.removeEventListener('mouseup', this.stopDrag);
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
position: {
|
||||
handler(newPos) {
|
||||
this.localPosition = { ...newPos };
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
if (this.isDragging) {
|
||||
this.stopDrag();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Base Dialog Wrapper */
|
||||
.base-dialog {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Modal Dialog (mit Overlay) */
|
||||
.modal-dialog {
|
||||
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;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Non-Modal Dialog (ohne Overlay) */
|
||||
.non-modal-dialog {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dialog Container */
|
||||
.dialog-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
/* Modal Dialog Container */
|
||||
.modal-dialog .dialog-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Non-Modal Dialog Container */
|
||||
.non-modal-dialog .dialog-container {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Größen */
|
||||
.dialog-small {
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.dialog-medium {
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.dialog-large {
|
||||
width: 900px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.dialog-fullscreen {
|
||||
width: 90vw;
|
||||
height: 90vh;
|
||||
max-width: none;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.dialog-header {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
padding: 4px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dialog-header.draggable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.dialog-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.dialog-body {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.dialog-footer {
|
||||
padding: 4px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.dialog-small,
|
||||
.dialog-medium,
|
||||
.dialog-large {
|
||||
width: 95vw;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.dialog-fullscreen {
|
||||
width: 95vw;
|
||||
height: 95vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
209
frontend/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="title"
|
||||
:is-modal="true"
|
||||
size="small"
|
||||
:closable="true"
|
||||
:close-on-overlay="false"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="confirm-content">
|
||||
<div v-if="icon" class="confirm-icon" :class="`icon-${type}`">
|
||||
{{ icon }}
|
||||
</div>
|
||||
<div class="confirm-message">
|
||||
{{ message }}
|
||||
</div>
|
||||
<div v-if="details" class="confirm-details">
|
||||
{{ details }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer mit Aktionen -->
|
||||
<template #footer>
|
||||
<button
|
||||
v-if="showCancel"
|
||||
@click="handleCancel"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleConfirm"
|
||||
:class="confirmButtonClass"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'ConfirmDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Bestätigung'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
details: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
validator: (value) => ['info', 'warning', 'danger', 'success'].includes(value)
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: 'OK'
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: 'Abbrechen'
|
||||
},
|
||||
showCancel: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
const icons = {
|
||||
info: 'ℹ️',
|
||||
warning: '⚠️',
|
||||
danger: '⛔',
|
||||
success: '✅'
|
||||
};
|
||||
return icons[this.type] || icons.info;
|
||||
},
|
||||
confirmButtonClass() {
|
||||
const classes = {
|
||||
info: 'btn-primary',
|
||||
warning: 'btn-warning',
|
||||
danger: 'btn-danger',
|
||||
success: 'btn-primary'
|
||||
};
|
||||
return classes[this.type] || 'btn-primary';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleConfirm() {
|
||||
this.$emit('confirm');
|
||||
this.$emit('update:modelValue', false);
|
||||
},
|
||||
handleCancel() {
|
||||
this.$emit('cancel');
|
||||
this.$emit('update:modelValue', false);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.confirm-content {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.icon-info {
|
||||
color: #17a2b8;
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.icon-danger {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.confirm-details {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-primary,
|
||||
.btn-warning,
|
||||
.btn-danger {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
</style>
|
||||
|
||||
168
frontend/src/components/CourtDrawingDialog.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
title="Tischtennis-Übung konfigurieren"
|
||||
size="large"
|
||||
:close-on-overlay="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<CourtDrawingTool
|
||||
ref="drawingTool"
|
||||
:drawing-data="initialDrawingData"
|
||||
:allow-image-upload="false"
|
||||
@update-drawing-data="handleDrawingDataUpdate"
|
||||
@update-fields="handleFieldsUpdate"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<button class="btn-secondary" @click="handleClose">Abbrechen</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
@click="handleOk"
|
||||
:disabled="!isValid"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
import CourtDrawingTool from './CourtDrawingTool.vue';
|
||||
|
||||
export default {
|
||||
name: 'CourtDrawingDialog',
|
||||
components: {
|
||||
BaseDialog,
|
||||
CourtDrawingTool
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
initialDrawingData: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'ok'],
|
||||
data() {
|
||||
return {
|
||||
currentDrawingData: null,
|
||||
currentFields: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isValid() {
|
||||
// Mindestens Aufschlag und Zielposition müssen gesetzt sein
|
||||
return this.currentDrawingData &&
|
||||
this.currentDrawingData.selectedStartPosition &&
|
||||
this.currentDrawingData.strokeType &&
|
||||
this.currentDrawingData.spinType &&
|
||||
this.currentDrawingData.targetPosition;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
initialDrawingData: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.currentDrawingData = { ...newVal };
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
},
|
||||
modelValue(newVal) {
|
||||
// Wenn Dialog geöffnet wird, stelle sicher dass Tool neu gezeichnet wird
|
||||
if (newVal) {
|
||||
this.$nextTick(() => {
|
||||
// Warte bis der Dialog vollständig gerendert ist
|
||||
setTimeout(() => {
|
||||
if (this.$refs.drawingTool && this.$refs.drawingTool.drawCourt) {
|
||||
this.$refs.drawingTool.drawCourt(true);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Wenn Dialog bereits beim Mount geöffnet ist
|
||||
if (this.modelValue) {
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
if (this.$refs.drawingTool && this.$refs.drawingTool.drawCourt) {
|
||||
this.$refs.drawingTool.drawCourt(true);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleDrawingDataUpdate(data) {
|
||||
this.currentDrawingData = { ...data };
|
||||
},
|
||||
handleFieldsUpdate(fields) {
|
||||
this.currentFields = { ...fields };
|
||||
},
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
handleOk() {
|
||||
if (this.isValid && this.currentDrawingData) {
|
||||
// Sammle alle Daten zusammen
|
||||
const result = {
|
||||
drawingData: { ...this.currentDrawingData },
|
||||
fields: this.currentFields ? { ...this.currentFields } : null,
|
||||
code: this.currentDrawingData.code || (this.currentFields ? this.currentFields.code : ''),
|
||||
name: this.currentFields ? this.currentFields.name : '',
|
||||
description: this.currentFields ? this.currentFields.description : ''
|
||||
};
|
||||
this.$emit('ok', result);
|
||||
this.handleClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<template>
|
||||
<div class="render-container">
|
||||
<div class="animation-controls" v-if="hasArrows">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-animate"
|
||||
@click="startAnimation"
|
||||
:disabled="isAnimating"
|
||||
>
|
||||
{{ isAnimating ? 'Animation läuft...' : 'Animation starten' }}
|
||||
</button>
|
||||
</div>
|
||||
<canvas
|
||||
ref="canvas"
|
||||
:width="config.canvas.width"
|
||||
@@ -31,6 +41,9 @@ export default {
|
||||
return {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
isAnimating: false,
|
||||
animationState: null, // { currentArrowIndex, startTime, arrowProgress }
|
||||
animationFrameId: null,
|
||||
config: {
|
||||
// Mehr Platz links für die drei Startkreise (Rand außerhalb des Tisches)
|
||||
canvas: { width: 640, height: 400 },
|
||||
@@ -65,6 +78,8 @@ export default {
|
||||
arrows: {
|
||||
primaryColor: '#d32f2f', // rechts -> target (rot)
|
||||
secondaryColor: '#1565c0', // zurück (blau)
|
||||
tertiaryColor: '#2e7d32', // dritter Schlag (grün)
|
||||
quaternaryColor: '#6a1b9a', // vierter Schlag (violett)
|
||||
width: 6,
|
||||
headLength: 24,
|
||||
vhOffsetX: 5,
|
||||
@@ -96,9 +111,23 @@ export default {
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasArrows() {
|
||||
if (!this.drawingData) return false;
|
||||
// Pfeile werden nur gezeichnet wenn ein main target existiert (tp)
|
||||
// Zusätzliche Pfeile werden nur gezeichnet wenn tp && extras.length
|
||||
// Daher muss hasArrows nur true sein, wenn ein main target existiert
|
||||
const tp = Number(this.drawingData.targetPosition);
|
||||
return !!tp;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
drawingData: {
|
||||
handler() {
|
||||
// Wenn sich drawingData ändert, stoppe laufende Animation
|
||||
if (this.isAnimating) {
|
||||
this.stopAnimation();
|
||||
}
|
||||
this.redraw();
|
||||
},
|
||||
deep: true,
|
||||
@@ -114,6 +143,10 @@ export default {
|
||||
this.redraw();
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Cleanup: stoppe Animation falls noch läuft
|
||||
this.stopAnimation();
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.canvas = this.$refs.canvas;
|
||||
@@ -219,9 +252,7 @@ export default {
|
||||
const botY = tableY + tableHeight - cfg.bottomYOffset;
|
||||
|
||||
// Mapping und Fallback: bei "AS" auf 'middle'
|
||||
const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' };
|
||||
const selKey = this.drawingData?.selectedStartPosition || 'AS';
|
||||
const selectedPos = map[selKey] || 'middle';
|
||||
const selectedPos = this.resolveStartPos();
|
||||
const y = selectedPos === 'top' ? topY : selectedPos === 'bottom' ? botY : midY;
|
||||
|
||||
ctx.fillStyle = cfg.selectedColor;
|
||||
@@ -267,8 +298,7 @@ export default {
|
||||
const tableX = (this.config.canvas.width - tblW) / 2;
|
||||
const tableY = (this.config.canvas.height - tblH) / 2;
|
||||
const circleX = tableX - sc.x; // Kreis links vor dem Tisch
|
||||
const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' };
|
||||
const pos = map[this.drawingData?.selectedStartPosition] || 'middle';
|
||||
const pos = this.resolveStartPos();
|
||||
const y = pos === 'top' ? tableY + sc.topYOffset : pos === 'bottom' ? tableY + tblH - sc.bottomYOffset : tableY + tblH / 2;
|
||||
const isVH = (this.drawingData?.strokeType || 'VH') === 'VH';
|
||||
const startX = isVH
|
||||
@@ -278,24 +308,35 @@ export default {
|
||||
const startYOffset = isVH ? (sc.radius + ar.vhOffsetY) : ar.rhOffsetY;
|
||||
return { x: startX, y: y + startYOffset };
|
||||
},
|
||||
drawArrow(ctx, from, to, color, label) {
|
||||
drawArrow(ctx, from, to, color, label, progress = 1.0) {
|
||||
// progress: 0.0 = nicht sichtbar, 1.0 = vollständig gezeichnet
|
||||
if (progress <= 0) return;
|
||||
|
||||
const { width, headLength } = this.config.arrows;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
|
||||
// Berechne aktuellen Endpunkt basierend auf progress
|
||||
const currentX = from.x + (to.x - from.x) * progress;
|
||||
const currentY = from.y + (to.y - from.y) * progress;
|
||||
|
||||
// line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.lineTo(currentX, currentY);
|
||||
ctx.stroke();
|
||||
// arrow head
|
||||
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(to.x - headLength * Math.cos(angle - Math.PI / 6), to.y - headLength * Math.sin(angle - Math.PI / 6));
|
||||
ctx.lineTo(to.x - headLength * Math.cos(angle + Math.PI / 6), to.y - headLength * Math.sin(angle + Math.PI / 6));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// arrow head nur zeichnen wenn progress > 0.1 (kleine Puffer für bessere Sichtbarkeit)
|
||||
if (progress > 0.1) {
|
||||
const angle = Math.atan2(currentY - from.y, currentX - from.x);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(currentX, currentY);
|
||||
ctx.lineTo(currentX - headLength * Math.cos(angle - Math.PI / 6), currentY - headLength * Math.sin(angle - Math.PI / 6));
|
||||
ctx.lineTo(currentX - headLength * Math.cos(angle + Math.PI / 6), currentY - headLength * Math.sin(angle + Math.PI / 6));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
drawLabelBelow(ctx, text, anchor) {
|
||||
if (!text) return;
|
||||
@@ -320,42 +361,165 @@ export default {
|
||||
const ctx = this.ctx;
|
||||
const from = this.getStartPoint();
|
||||
|
||||
// First arrow: to right target
|
||||
// Sammle alle Pfeile für Animation
|
||||
const allArrows = [];
|
||||
const tp = Number(this.drawingData.targetPosition);
|
||||
|
||||
// First arrow: to right target
|
||||
if (tp) {
|
||||
const to = this.computeRightTargetPosition(tp);
|
||||
// Zielmarkierung (unter dem Pfeilkopf)
|
||||
this.drawHitMarker(ctx, to);
|
||||
const strokeSide = this.drawingData.strokeType || '';
|
||||
const spinAbbrev = this.abbrevSpin(this.drawingData.spinType);
|
||||
// Text gehört an die Quelle (ohne "target")
|
||||
const sourceLabel = `${strokeSide} ${spinAbbrev}`.trim();
|
||||
const toEnd = { x: to.x - this.config.targetCircles.radius, y: to.y };
|
||||
this.drawArrow(ctx, from, toEnd, this.config.arrows.primaryColor);
|
||||
// Unter dem Startkreis beschriften
|
||||
const startCenter = this.getStartCircleCenter();
|
||||
this.drawLabelBelow(ctx, sourceLabel, startCenter);
|
||||
allArrows.push({
|
||||
from: from,
|
||||
to: toEnd,
|
||||
toCenter: to,
|
||||
color: this.config.arrows.primaryColor,
|
||||
index: 0,
|
||||
label: `${this.drawingData.strokeType || ''} ${this.abbrevSpin(this.drawingData.spinType)}`.trim(),
|
||||
labelAnchor: this.getStartCircleCenter()
|
||||
});
|
||||
}
|
||||
|
||||
// Second arrow (optional): from right source to left target
|
||||
const leftTarget = this.drawingData.nextStrokeTargetPosition ? Number(this.drawingData.nextStrokeTargetPosition) : null;
|
||||
if (tp && leftTarget) {
|
||||
// source near previous right target
|
||||
|
||||
// Additional arrows
|
||||
const extras = Array.isArray(this.drawingData.additionalStrokes) ? this.drawingData.additionalStrokes : [];
|
||||
if (tp && extras.length) {
|
||||
const colors = [
|
||||
this.config.arrows.secondaryColor,
|
||||
this.config.arrows.tertiaryColor,
|
||||
this.config.arrows.quaternaryColor
|
||||
];
|
||||
const max = extras.length;
|
||||
const sourceRightCenter = this.computeRightTargetPosition(tp);
|
||||
// left target mapping: mirror scheme to left half
|
||||
const toLeftCenter = this.computeLeftTargetPosition(leftTarget);
|
||||
// Zielmarkierung links
|
||||
this.drawHitMarker(ctx, toLeftCenter);
|
||||
const side = this.drawingData.nextStrokeSide || '';
|
||||
const type = this.drawingData.nextStrokeType || '';
|
||||
// Text gehört ans Ziel (ohne "extra target")
|
||||
const targetLabel = `${side} ${type}`.trim();
|
||||
const sourceRight = { x: sourceRightCenter.x - this.config.targetCircles.radius, y: sourceRightCenter.y };
|
||||
const toLeft = { x: toLeftCenter.x + this.config.leftTargetCircles.radius, y: toLeftCenter.y };
|
||||
this.drawArrow(ctx, sourceRight, toLeft, this.config.arrows.secondaryColor);
|
||||
// Unter dem rechten Ziel (target der ersten Linie) beschriften
|
||||
this.drawLabelBelow(ctx, targetLabel, sourceRightCenter);
|
||||
let prevPoint = { x: sourceRightCenter.x - this.config.targetCircles.radius, y: sourceRightCenter.y };
|
||||
for (let i = 0; i < max; i++) {
|
||||
const stroke = extras[i];
|
||||
const side = i % 2 === 0 ? 'left' : 'right';
|
||||
let toCenter, toPoint;
|
||||
if (side === 'left') {
|
||||
const leftNum = Number(stroke.targetPosition);
|
||||
toCenter = this.computeLeftTargetPosition(leftNum);
|
||||
toPoint = { x: toCenter.x + this.config.leftTargetCircles.radius, y: toCenter.y };
|
||||
} else {
|
||||
const rightNum = Number(stroke.targetPosition);
|
||||
toCenter = this.computeRightTargetPosition(rightNum);
|
||||
toPoint = { x: toCenter.x - this.config.targetCircles.radius, y: toCenter.y };
|
||||
}
|
||||
allArrows.push({
|
||||
from: prevPoint,
|
||||
to: toPoint,
|
||||
toCenter: toCenter,
|
||||
color: colors[i % colors.length], // Zyklisch durch die verfügbaren Farben wechseln
|
||||
index: i + 1
|
||||
});
|
||||
prevPoint = toPoint;
|
||||
}
|
||||
}
|
||||
|
||||
// Zeichne Pfeile basierend auf Animationsstatus
|
||||
allArrows.forEach((arrow, idx) => {
|
||||
let progress = 1.0;
|
||||
if (this.isAnimating && this.animationState) {
|
||||
const state = this.animationState;
|
||||
if (idx < state.currentArrowIndex) {
|
||||
// Pfeil bereits vollständig gezeichnet
|
||||
progress = 1.0;
|
||||
} else if (idx === state.currentArrowIndex) {
|
||||
// Aktuell animierter Pfeil
|
||||
progress = state.arrowProgress;
|
||||
} else {
|
||||
// Pfeil noch nicht dran
|
||||
progress = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Zeichne Pfeil
|
||||
this.drawArrow(ctx, arrow.from, arrow.to, arrow.color, null, progress);
|
||||
|
||||
// Zeichne Zielmarkierung nur wenn Pfeil vollständig animiert wurde
|
||||
if (progress >= 1.0 && arrow.toCenter) {
|
||||
this.drawHitMarker(ctx, arrow.toCenter);
|
||||
}
|
||||
|
||||
// Zeichne Label nur wenn Pfeil vollständig
|
||||
if (progress >= 1.0 && arrow.label && arrow.labelAnchor) {
|
||||
this.drawLabelBelow(ctx, arrow.label, arrow.labelAnchor);
|
||||
}
|
||||
});
|
||||
},
|
||||
applyRenderCode(code) {
|
||||
try {
|
||||
const startMatch = code.match(/ASv([LMR])/);
|
||||
if (startMatch) {
|
||||
const map = { L: 'AS1', M: 'AS2', R: 'AS3' };
|
||||
this.drawingData.selectedStartPosition = map[startMatch[1]] || 'AS2';
|
||||
this.drawingData.selectedCirclePosition = this.drawingData.selectedStartPosition === 'AS1' ? 'top' : this.drawingData.selectedStartPosition === 'AS3' ? 'bottom' : 'middle';
|
||||
}
|
||||
// Hauptziel extrahieren (nach dem ersten → )
|
||||
const arrowParts = code.split('→').map(s => s.trim());
|
||||
if (arrowParts.length >= 2) {
|
||||
const mainLabel = arrowParts[1].split('/')[0].trim();
|
||||
const mainNum = this.labelToNumber(mainLabel);
|
||||
if (mainNum) {
|
||||
this.drawingData.targetPosition = String(mainNum);
|
||||
}
|
||||
}
|
||||
// Zusatzschläge: Segmente nach '/'
|
||||
const extras = [];
|
||||
if (code.includes('/')) {
|
||||
const extraSegments = code.split('/').slice(1);
|
||||
extraSegments.forEach(seg => {
|
||||
const m = seg.split('→')[1];
|
||||
if (m) {
|
||||
const lbl = m.trim();
|
||||
const num = this.labelToNumber(lbl);
|
||||
if (num) {
|
||||
extras.push({ targetPosition: String(num) });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
this.drawingData.additionalStrokes = extras;
|
||||
} catch (e) {
|
||||
// still draw with whatever is present
|
||||
}
|
||||
},
|
||||
labelToNumber(label) {
|
||||
const map = {
|
||||
'VH L': 1, 'M L': 2, 'RH L': 3,
|
||||
'VH H': 4, 'M H': 5, 'RH H': 6,
|
||||
'VH K': 7, 'M K': 8, 'RH K': 9
|
||||
};
|
||||
return map[label] || null;
|
||||
},
|
||||
buildCodeString() {
|
||||
if (!this.drawingData) return '';
|
||||
// Wenn ein expliziter renderCode mitgeliefert wird, verwende den
|
||||
if (this.drawingData && (this.drawingData.code || this.drawingData.renderCode)) {
|
||||
return this.drawingData.code || this.drawingData.renderCode;
|
||||
}
|
||||
// sonst: Nutze die gleiche Logik wie beim Zeichnen (resolveStartPos)
|
||||
const pos = this.resolveStartPos(); // 'top' | 'middle' | 'bottom'
|
||||
const start = pos === 'top' ? 'vL' : pos === 'bottom' ? 'vR' : 'vM';
|
||||
const strokeSide = this.drawingData.strokeType || '';
|
||||
const spin = this.abbrevSpin(this.drawingData.spinType);
|
||||
const mainTargetMap = {
|
||||
'1': 'VH L', '2': 'M L', '3': 'RH L',
|
||||
'4': 'VH H', '5': 'M H', '6': 'RH H',
|
||||
'7': 'VH K', '8': 'M K', '9': 'RH K'
|
||||
};
|
||||
let code = `AS${start} ${strokeSide} ${spin}`.trim();
|
||||
if (this.drawingData.targetPosition) {
|
||||
const main = mainTargetMap[String(this.drawingData.targetPosition)] || this.drawingData.targetPosition;
|
||||
code += ` → ${main}`;
|
||||
}
|
||||
const extras = Array.isArray(this.drawingData.additionalStrokes) ? this.drawingData.additionalStrokes : [];
|
||||
if (extras.length) {
|
||||
extras.forEach(stroke => {
|
||||
const tgt = mainTargetMap[String(stroke.targetPosition)] || stroke.targetPosition;
|
||||
code += ` / ${stroke.side} ${stroke.type} → ${tgt}`;
|
||||
});
|
||||
}
|
||||
return code;
|
||||
},
|
||||
getStartCircleCenter() {
|
||||
const cfg = this.config.startCircles;
|
||||
@@ -364,15 +528,25 @@ export default {
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
const circleX = tableX - cfg.x;
|
||||
const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' };
|
||||
const selKey = this.drawingData?.selectedStartPosition || 'AS';
|
||||
const selectedPos = map[selKey] || 'middle';
|
||||
const selectedPos = this.resolveStartPos();
|
||||
const topY = tableY + cfg.topYOffset;
|
||||
const midY = tableY + tableHeight / 2;
|
||||
const botY = tableY + tableHeight - cfg.bottomYOffset;
|
||||
const y = selectedPos === 'top' ? topY : selectedPos === 'bottom' ? botY : midY;
|
||||
return { x: circleX, y };
|
||||
},
|
||||
resolveStartPos() {
|
||||
// 1) bevorzugt AS1/AS2/AS3 aus drawingData.selectedStartPosition
|
||||
const key = this.drawingData?.selectedStartPosition;
|
||||
if (key === 'AS1') return 'top';
|
||||
if (key === 'AS2') return 'middle';
|
||||
if (key === 'AS3') return 'bottom';
|
||||
// 2) Fallback über selectedCirclePosition (top/middle/bottom)
|
||||
const circlePos = this.drawingData?.selectedCirclePosition;
|
||||
if (circlePos === 'top' || circlePos === 'middle' || circlePos === 'bottom') return circlePos;
|
||||
// 3) Default Mitte
|
||||
return 'middle';
|
||||
},
|
||||
drawHitMarker(ctx, pos) {
|
||||
if (!pos) return;
|
||||
const mk = this.config.hitMarker;
|
||||
@@ -424,6 +598,73 @@ export default {
|
||||
Gegenläufer: 'GL'
|
||||
};
|
||||
return map[spin] || spin;
|
||||
},
|
||||
startAnimation() {
|
||||
if (this.isAnimating || !this.hasArrows) return;
|
||||
|
||||
// Zähle Anzahl der Pfeile - muss exakt der Logik in drawArrowsAndLabels() entsprechen
|
||||
const tp = Number(this.drawingData.targetPosition);
|
||||
const extras = Array.isArray(this.drawingData.additionalStrokes) ? this.drawingData.additionalStrokes : [];
|
||||
// Erster Pfeil wird nur hinzugefügt wenn tp truthy
|
||||
// Zusätzliche Pfeile werden nur hinzugefügt wenn tp truthy UND extras.length > 0
|
||||
const mainArrow = tp ? 1 : 0;
|
||||
const additionalArrows = (tp && extras.length) ? extras.length : 0;
|
||||
const totalArrows = mainArrow + additionalArrows;
|
||||
|
||||
if (totalArrows === 0) return;
|
||||
|
||||
this.isAnimating = true;
|
||||
this.animationState = {
|
||||
currentArrowIndex: 0,
|
||||
startTime: performance.now(),
|
||||
arrowProgress: 0.0,
|
||||
totalArrows: totalArrows
|
||||
};
|
||||
|
||||
this.animateFrame();
|
||||
},
|
||||
animateFrame() {
|
||||
if (!this.isAnimating || !this.animationState) {
|
||||
this.stopAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const state = this.animationState;
|
||||
const elapsed = now - state.startTime;
|
||||
const durationPerArrow = 1000; // 1 Sekunde pro Pfeil
|
||||
|
||||
// Berechne aktuellen Pfeil-Index basierend auf vergangener Zeit
|
||||
const currentArrowIndex = Math.floor(elapsed / durationPerArrow);
|
||||
|
||||
if (currentArrowIndex >= state.totalArrows) {
|
||||
// Animation abgeschlossen - alle Pfeile wurden animiert
|
||||
this.stopAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Berechne Progress für den aktuellen Pfeil (0.0 bis 1.0 innerhalb seiner Sekunde)
|
||||
const timeWithinCurrentArrow = elapsed % durationPerArrow;
|
||||
const arrowProgress = Math.min(1.0, timeWithinCurrentArrow / durationPerArrow);
|
||||
|
||||
state.currentArrowIndex = currentArrowIndex;
|
||||
state.arrowProgress = arrowProgress;
|
||||
|
||||
// Neuzeichnen
|
||||
this.redraw();
|
||||
|
||||
// Nächster Frame
|
||||
this.animationFrameId = requestAnimationFrame(() => this.animateFrame());
|
||||
},
|
||||
stopAnimation() {
|
||||
this.isAnimating = false;
|
||||
this.animationState = null;
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
// Finales Redraw um alle Pfeile vollständig zu zeigen
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -433,7 +674,41 @@ export default {
|
||||
.render-container {
|
||||
width: 100%;
|
||||
}
|
||||
canvas { display: block; max-width: 100%; height: auto; }
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.animation-controls {
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn-animate {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid #007bff;
|
||||
border-radius: 4px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-animate:hover:not(:disabled) {
|
||||
background-color: #0056b3;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-animate:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
<div class="selection-group">
|
||||
<!-- Schlagart Auswahl -->
|
||||
<div class="stroke-selection">
|
||||
<span>Aufschlag:</span>
|
||||
<div>
|
||||
<span class="group-label">Aufschlag:</span>
|
||||
<div class="stroke-buttons">
|
||||
<button
|
||||
type="button"
|
||||
@@ -41,9 +42,11 @@
|
||||
RH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schnittoption Auswahl -->
|
||||
<div class="spin-selection">
|
||||
<span class="group-label">Schnitt:</span>
|
||||
<div class="spin-buttons">
|
||||
<button
|
||||
type="button"
|
||||
@@ -87,7 +90,23 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zielposition für Hauptschlag: explizite Auswahl 1–9 als Buttons -->
|
||||
<div class="target-selection" v-if="spinType">
|
||||
<span class="group-label">Zielposition:</span>
|
||||
<div class="target-grid">
|
||||
<button
|
||||
v-for="n in mainTargetPositions"
|
||||
:key="`main-target-${n}`"
|
||||
type="button"
|
||||
:class="['grid-btn', { 'is-active': targetPosition === String(n) }]"
|
||||
@click="targetPosition = String(n)"
|
||||
>
|
||||
{{ n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Zusätzliche Schläge hinzufügen -->
|
||||
@@ -95,6 +114,7 @@
|
||||
<div class="next-stroke-selection">
|
||||
<!-- Schlagart für zusätzlichen Schlag -->
|
||||
<div class="next-stroke-type">
|
||||
<span class="group-label">Seite:</span>
|
||||
<button
|
||||
type="button"
|
||||
:class="['btn-small', 'btn-stroke', { 'btn-primary': nextStrokeSide === 'VH', 'btn-secondary': nextStrokeSide !== 'VH' }]"
|
||||
@@ -114,6 +134,7 @@
|
||||
</div>
|
||||
|
||||
<div class="next-stroke-buttons">
|
||||
<span class="group-label">Schlagart:</span>
|
||||
<button
|
||||
type="button"
|
||||
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'US', 'btn-secondary': nextStrokeType !== 'US' }]"
|
||||
@@ -140,25 +161,66 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'FL', 'btn-secondary': nextStrokeType !== 'FL' }]"
|
||||
@click="nextStrokeType = 'FL'"
|
||||
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'F', 'btn-secondary': nextStrokeType !== 'F' }]"
|
||||
@click="nextStrokeType = 'F'"
|
||||
title="Flip"
|
||||
>
|
||||
FL
|
||||
F
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'BL', 'btn-secondary': nextStrokeType !== 'BL' }]"
|
||||
@click="nextStrokeType = 'BL'"
|
||||
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'B', 'btn-secondary': nextStrokeType !== 'B' }]"
|
||||
@click="nextStrokeType = 'B'"
|
||||
title="Block"
|
||||
>
|
||||
BL
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'SCH', 'btn-secondary': nextStrokeType !== 'SCH' }]"
|
||||
@click="nextStrokeType = 'SCH'"
|
||||
title="Schuss"
|
||||
>
|
||||
SCH
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'SAB', 'btn-secondary': nextStrokeType !== 'SAB' }]"
|
||||
@click="nextStrokeType = 'SAB'"
|
||||
title="Schnittabwehr"
|
||||
>
|
||||
SAB
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'BAB', 'btn-secondary': nextStrokeType !== 'BAB' }]"
|
||||
@click="nextStrokeType = 'BAB'"
|
||||
title="Ballonabwehr"
|
||||
>
|
||||
BAB
|
||||
</button>
|
||||
</div>
|
||||
<!-- Zielposition für Zusatzschlag: explizite Auswahl 1–9 als Buttons -->
|
||||
<div class="next-target-selection">
|
||||
<span>Zielposition:</span>
|
||||
<div class="target-grid">
|
||||
<button
|
||||
v-for="n in additionalTargetPositions"
|
||||
:key="`next-target-${n}`"
|
||||
type="button"
|
||||
:class="['grid-btn', { 'is-active': nextStrokeTargetPosition === String(n) }]"
|
||||
@click="nextStrokeTargetPosition = String(n)"
|
||||
>
|
||||
{{ n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary btn-small"
|
||||
style="vertical-align:bottom"
|
||||
@click="addNextStroke"
|
||||
:disabled="additionalStrokes.length >= 4"
|
||||
>
|
||||
Schlag hinzufügen
|
||||
</button>
|
||||
@@ -277,7 +339,7 @@ export default {
|
||||
textColor: '#000000'
|
||||
},
|
||||
arrow: {
|
||||
color: '#ff0000',
|
||||
color: '#ff0000', // Hauptschlag immer rot
|
||||
width: 6,
|
||||
cap: 'round',
|
||||
headSize: 8,
|
||||
@@ -317,6 +379,28 @@ export default {
|
||||
nextStrokeTargetPosition: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// Reihenfolge der Positionen für Hauptschlag basierend auf Richtung
|
||||
mainTargetPositions() {
|
||||
const isLeftToRight = this.isMainStrokeLeftToRight();
|
||||
// Links nach rechts: 7 4 1 / 8 5 2 / 9 6 3
|
||||
// Rechts nach links: 3 6 9 / 2 5 8 / 1 4 7
|
||||
if (isLeftToRight) {
|
||||
return [7, 4, 1, 8, 5, 2, 9, 6, 3];
|
||||
} else {
|
||||
return [3, 6, 9, 2, 5, 8, 1, 4, 7];
|
||||
}
|
||||
},
|
||||
// Reihenfolge der Positionen für zusätzliche Schläge basierend auf Richtung
|
||||
additionalTargetPositions() {
|
||||
const isLeftToRight = this.isAdditionalStrokeLeftToRight();
|
||||
if (isLeftToRight) {
|
||||
return [7, 4, 1, 8, 5, 2, 9, 6, 3];
|
||||
} else {
|
||||
return [3, 6, 9, 2, 5, 8, 1, 4, 7];
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
strokeType() {
|
||||
this.emitDrawingData();
|
||||
@@ -331,12 +415,21 @@ export default {
|
||||
this.emitDrawingData();
|
||||
this.updateTextFields();
|
||||
},
|
||||
selectedCirclePosition() {
|
||||
// bei Wechsel der Kreisposition: Daten + Strings aktualisieren
|
||||
this.emitDrawingData();
|
||||
this.updateTextFields();
|
||||
if (this.canvas && this.ctx) {
|
||||
this.drawCourt();
|
||||
}
|
||||
},
|
||||
selectedStartPosition() {
|
||||
// Neu zeichnen wenn sich die Auswahl ändert
|
||||
if (this.canvas && this.ctx) {
|
||||
this.drawCourt();
|
||||
}
|
||||
this.emitDrawingData();
|
||||
this.updateTextFields();
|
||||
},
|
||||
drawingData: {
|
||||
handler(newVal, oldVal) {
|
||||
@@ -368,8 +461,6 @@ export default {
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.ctx.lineCap = this.config.pen.cap;
|
||||
this.ctx.lineJoin = this.config.pen.join;
|
||||
} else {
|
||||
console.error('CourtDrawingTool: Canvas not found!');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -379,7 +470,6 @@ export default {
|
||||
const config = this.config;
|
||||
|
||||
if (!ctx || !canvas) {
|
||||
console.error('CourtDrawingTool: Canvas or context not available');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -588,9 +678,9 @@ export default {
|
||||
this.drawLeftSideTargetCircles(ctx, tableX, tableY, tableWidth, tableHeight);
|
||||
}
|
||||
|
||||
// Zusätzlichen Pfeil vom rechten Source zum linken Target zeichnen (wenn linker Target ausgewählt)
|
||||
if (this.nextStrokeTargetPosition && this.targetPosition) {
|
||||
this.drawArrowToLeftTarget(ctx, tableX, tableY, tableWidth, tableHeight);
|
||||
// Zeichne Kette zusätzlicher Schläge (bis zu 3)
|
||||
if (this.targetPosition) {
|
||||
this.drawAdditionalArrows(ctx, tableX, tableY, tableWidth, tableHeight);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -600,19 +690,17 @@ export default {
|
||||
const circleRadius = config.startCircles.radius;
|
||||
const circleX = config.startCircles.x;
|
||||
let startY;
|
||||
|
||||
switch(this.selectedCirclePosition) {
|
||||
case 'top':
|
||||
startY = tableY + config.startCircles.topYOffset;
|
||||
break;
|
||||
case 'middle':
|
||||
startY = tableY + tableHeight / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
startY = tableY + tableHeight - config.startCircles.bottomYOffset;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
// Bestimme Startkreis primär aus selectedStartPosition (AS1/AS2/AS3),
|
||||
// fallback auf selectedCirclePosition
|
||||
const circlePos = this.resolveStartCirclePosition();
|
||||
if (circlePos === 'top') {
|
||||
startY = tableY + config.startCircles.topYOffset;
|
||||
} else if (circlePos === 'middle') {
|
||||
startY = tableY + tableHeight / 2;
|
||||
} else if (circlePos === 'bottom') {
|
||||
startY = tableY + tableHeight - config.startCircles.bottomYOffset;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Zielposition-Kreis Koordinaten ermitteln
|
||||
@@ -702,6 +790,15 @@ export default {
|
||||
);
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
// Leitet die visuelle Kreis-Position aus selectedStartPosition ab
|
||||
resolveStartCirclePosition() {
|
||||
if (this.selectedStartPosition === 'AS1') return 'top';
|
||||
if (this.selectedStartPosition === 'AS2') return 'middle';
|
||||
if (this.selectedStartPosition === 'AS3') return 'bottom';
|
||||
// Fallback: benutze bestehenden Kreisstatus
|
||||
return this.selectedCirclePosition;
|
||||
},
|
||||
|
||||
drawArrowToLeftTarget(ctx, tableX, tableY, tableWidth, tableHeight) {
|
||||
const config = this.config;
|
||||
@@ -821,6 +918,156 @@ export default {
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
// Ermittelt, welche Tischseite (left/right) als nächstes für Zusatzschlag benutzt wird
|
||||
getNextAdditionalSide() {
|
||||
const idx = this.additionalStrokes.length; // 0,1,2
|
||||
return idx % 2 === 0 ? 'left' : 'right';
|
||||
},
|
||||
|
||||
// Bestimmt ob der Hauptschlag von links nach rechts geht
|
||||
isMainStrokeLeftToRight() {
|
||||
// Bei Aufschlag immer links nach rechts
|
||||
if (this.selectedStartPosition && this.selectedStartPosition.startsWith('AS')) {
|
||||
return true;
|
||||
}
|
||||
// Wenn von links kommt, dann links nach rechts
|
||||
if (this.selectedCirclePosition === 'left') {
|
||||
return true;
|
||||
}
|
||||
// Wenn von rechts kommt, dann rechts nach links
|
||||
if (this.selectedCirclePosition === 'right') {
|
||||
return false;
|
||||
}
|
||||
// Standard: links nach rechts
|
||||
return true;
|
||||
},
|
||||
|
||||
// Bestimmt ob der nächste zusätzliche Schlag von links nach rechts geht
|
||||
isAdditionalStrokeLeftToRight() {
|
||||
// Wenn der Hauptschlag ein Aufschlag ist, endet er rechts
|
||||
// Dann kommt der erste zusätzliche Schlag von rechts → rechts nach links → false
|
||||
// Der zweite zusätzliche Schlag kommt dann von links → links nach rechts → true
|
||||
// etc.
|
||||
|
||||
// Wenn der Hauptschlag kein Aufschlag ist und von links kommt, endet er rechts
|
||||
// Dann kommt der erste zusätzliche Schlag von rechts → rechts nach links → false
|
||||
|
||||
// Wenn der Hauptschlag von rechts kommt, endet er links
|
||||
// Dann kommt der erste zusätzliche Schlag von links → links nach rechts → true
|
||||
|
||||
const nextIdx = this.additionalStrokes.length;
|
||||
const isMainStrokeEndingRight = this.isMainStrokeLeftToRight();
|
||||
|
||||
// Wenn der Hauptschlag rechts endet (links nach rechts), dann:
|
||||
// - erster zusätzlicher Schlag kommt von rechts → rechts nach links → false
|
||||
// - zweiter zusätzlicher Schlag kommt von links → links nach rechts → true
|
||||
if (isMainStrokeEndingRight) {
|
||||
return nextIdx % 2 === 1; // ungerade Index = links nach rechts
|
||||
} else {
|
||||
// Wenn der Hauptschlag links endet (rechts nach links), dann:
|
||||
// - erster zusätzlicher Schlag kommt von links → links nach rechts → true
|
||||
// - zweiter zusätzlicher Schlag kommt von rechts → rechts nach links → false
|
||||
return nextIdx % 2 === 0; // gerade Index = links nach rechts
|
||||
}
|
||||
},
|
||||
|
||||
// Berechne Mittelpunkt eines rechten Zielkreises (1..9)
|
||||
computeRightTargetCenter(tableX, tableY, tableWidth, tableHeight, number) {
|
||||
const cfg = this.config.targetCircles;
|
||||
const x1 = tableX + tableWidth - cfg.rightXOffset;
|
||||
const x3 = tableX + tableWidth/2 + cfg.middleXOffset;
|
||||
const xdiff = x3 - x1;
|
||||
const x2 = x3 - xdiff/2;
|
||||
const yTop = tableY + cfg.topYOffset;
|
||||
const yMid = tableY + tableHeight/2;
|
||||
const yBot = tableY + tableHeight - cfg.bottomYOffset;
|
||||
const map = {
|
||||
1: { x: x1, y: yTop }, 2: { x: x1, y: yMid }, 3: { x: x1, y: yBot },
|
||||
4: { x: x2, y: yTop }, 5: { x: x2, y: yMid }, 6: { x: x2, y: yBot },
|
||||
7: { x: x3, y: yTop }, 8: { x: x3, y: yMid }, 9: { x: x3, y: yBot }
|
||||
};
|
||||
return map[number] || null;
|
||||
},
|
||||
// Berechne Mittelpunkt eines linken Zielkreises (1..9 gespiegelt)
|
||||
computeLeftTargetCenter(tableX, tableY, tableWidth, tableHeight, number) {
|
||||
const cfg = this.config.leftTargetCircles;
|
||||
const x1 = tableX + cfg.leftXOffset;
|
||||
const x3 = tableX + tableWidth / 2 - cfg.rightXOffset;
|
||||
const xdiff = x3 - x1;
|
||||
const x2 = x3 - xdiff/2;
|
||||
const yTop = tableY + cfg.topYOffset;
|
||||
const yMid = tableY + tableHeight/2;
|
||||
const yBot = tableY + tableHeight - cfg.bottomYOffset;
|
||||
const map = {
|
||||
1: { x: x1, y: yBot }, 2: { x: x1, y: yMid }, 3: { x: x1, y: yTop },
|
||||
4: { x: x2, y: yBot }, 5: { x: x2, y: yMid }, 6: { x: x2, y: yTop },
|
||||
7: { x: x3, y: yBot }, 8: { x: x3, y: yMid }, 9: { x: x3, y: yTop }
|
||||
};
|
||||
return map[number] || null;
|
||||
},
|
||||
|
||||
// Zeichnet zusätzliche Pfeile abwechselnd rechts/links aus additionalStrokes
|
||||
drawAdditionalArrows(ctx, tableX, tableY, tableWidth, tableHeight) {
|
||||
if (!this.additionalStrokes || this.additionalStrokes.length === 0) return;
|
||||
const max = this.additionalStrokes.length;
|
||||
// 1: rot, 2: blau, 3: gelb, 4: violett, ab 5: pink
|
||||
const colors = ['#007bff', '#FFD700', '#6a1b9a', '#ff69b4'];
|
||||
const rightRadius = this.config.targetCircles.radius;
|
||||
const leftRadius = this.config.leftTargetCircles.radius;
|
||||
|
||||
// Quelle: Ende des ersten Pfeils (rechts)
|
||||
const firstNum = parseInt(this.targetPosition);
|
||||
const firstCenter = this.computeRightTargetCenter(tableX, tableY, tableWidth, tableHeight, firstNum);
|
||||
let prevPoint = { x: firstCenter.x - rightRadius, y: firstCenter.y }; // linke Kante des rechten Kreises
|
||||
let prevSide = 'right';
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const stroke = this.additionalStrokes[i];
|
||||
const side = i % 2 === 0 ? 'left' : 'right';
|
||||
const targetNum = parseInt(stroke.targetPosition);
|
||||
// Farben: 2. blau, 3. gelb, 4. violett, ab dem 5. pink
|
||||
const color = (i < colors.length) ? colors[i] : '#ff69b4';
|
||||
let toCenter, toPoint;
|
||||
if (side === 'left') {
|
||||
toCenter = this.computeLeftTargetCenter(tableX, tableY, tableWidth, tableHeight, targetNum);
|
||||
toPoint = { x: toCenter.x + leftRadius, y: toCenter.y }; // rechte Kante des linken Kreises
|
||||
} else {
|
||||
toCenter = this.computeRightTargetCenter(tableX, tableY, tableWidth, tableHeight, targetNum);
|
||||
toPoint = { x: toCenter.x - rightRadius, y: toCenter.y }; // linke Kante des rechten Kreises
|
||||
}
|
||||
// Linie
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.lineWidth = this.config.arrow.width;
|
||||
ctx.lineCap = this.config.arrow.cap;
|
||||
// Nummer
|
||||
ctx.font = this.config.arrow.counterFont;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const midX = (prevPoint.x + toPoint.x) / 2;
|
||||
const midY = (prevPoint.y + toPoint.y) / 2 - this.config.arrow.counterOffset;
|
||||
ctx.fillText(String(i + 2), midX, midY);
|
||||
// Linie zeichnen
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(prevPoint.x, prevPoint.y);
|
||||
ctx.lineTo(toPoint.x, toPoint.y);
|
||||
ctx.stroke();
|
||||
// Pfeilkopf
|
||||
const angle = Math.atan2(toPoint.y - prevPoint.y, toPoint.x - prevPoint.x);
|
||||
const sz = this.config.arrow.headSize;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toPoint.x, toPoint.y);
|
||||
ctx.lineTo(toPoint.x - sz * Math.cos(angle - Math.PI / 6), toPoint.y - sz * Math.sin(angle - Math.PI / 6));
|
||||
ctx.moveTo(toPoint.x, toPoint.y);
|
||||
ctx.lineTo(toPoint.x - sz * Math.cos(angle + Math.PI / 6), toPoint.y - sz * Math.sin(angle + Math.PI / 6));
|
||||
ctx.stroke();
|
||||
|
||||
// nächste Quelle ist die Pfeilspitze dieses Schlages
|
||||
prevPoint = toPoint;
|
||||
prevSide = side;
|
||||
}
|
||||
},
|
||||
|
||||
drawLeftSideTargetCircles(ctx, tableX, tableY, tableWidth, tableHeight) {
|
||||
const config = this.config.leftTargetCircles;
|
||||
// Kreise auf der linken Tischhälfte (spiegelbildlich zu rechts)
|
||||
@@ -885,22 +1132,31 @@ export default {
|
||||
// Prüfe ob auf eine Zielposition geklickt wurde
|
||||
const clickedTarget = this.checkTargetPositionClick(clickX, clickY);
|
||||
if (clickedTarget) {
|
||||
this.targetPosition = clickedTarget;
|
||||
|
||||
// Wenn keine Startposition ausgewählt ist, setze standardmäßig die mittlere Startposition (AS2)
|
||||
if (!this.selectedStartPosition) {
|
||||
this.selectedStartPosition = 'AS2';
|
||||
this.selectedCirclePosition = 'middle';
|
||||
// Wenn noch kein Hauptziel gesetzt: setze es
|
||||
if (!this.targetPosition) {
|
||||
this.targetPosition = clickedTarget;
|
||||
// Wenn keine Startposition ausgewählt ist, setze standardmäßig die mittlere Startposition (AS2)
|
||||
if (!this.selectedStartPosition) {
|
||||
this.selectedStartPosition = 'AS2';
|
||||
this.selectedCirclePosition = 'middle';
|
||||
}
|
||||
this.drawCourt();
|
||||
this.emitDrawingData();
|
||||
this.updateTextFields();
|
||||
return;
|
||||
}
|
||||
// Andernfalls: wenn wir einen Zusatzschlag auf die rechte Seite wählen sollen
|
||||
if (this.targetPosition && this.additionalStrokes.length < 4 && this.getNextAdditionalSide() === 'right') {
|
||||
this.nextStrokeTargetPosition = clickedTarget;
|
||||
this.drawCourt();
|
||||
this.emitDrawingData();
|
||||
this.updateTextFields();
|
||||
return;
|
||||
}
|
||||
|
||||
this.drawCourt(); // Neu zeichnen für Zielposition-Hervorhebung
|
||||
this.emitDrawingData();
|
||||
this.updateTextFields();
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe ob auf eine linke Zielposition geklickt wurde (nur wenn Schlag für rechte Seite ausgewählt)
|
||||
if (this.nextStrokeType && this.nextStrokeSide && this.targetPosition) {
|
||||
// Prüfe ob auf eine linke Zielposition geklickt wurde (nur wenn Zusatzschlag links erwartet)
|
||||
if (this.nextStrokeType && this.nextStrokeSide && this.targetPosition && this.getNextAdditionalSide() === 'left') {
|
||||
const clickedLeftTarget = this.checkLeftTargetPositionClick(clickX, clickY);
|
||||
if (clickedLeftTarget) {
|
||||
this.nextStrokeTargetPosition = clickedLeftTarget;
|
||||
@@ -1061,25 +1317,6 @@ export default {
|
||||
this.drawCourt();
|
||||
},
|
||||
|
||||
testDraw() {
|
||||
|
||||
if (!this.canvas || !this.ctx) {
|
||||
console.error('Canvas or context not available, trying to reinitialize...');
|
||||
this.initCanvas();
|
||||
}
|
||||
|
||||
if (this.canvas && this.ctx) {
|
||||
|
||||
// Einfacher Test: Roter Kreis
|
||||
this.ctx.fillStyle = 'red';
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(300, 200, 50, 0, 2 * Math.PI);
|
||||
this.ctx.fill();
|
||||
|
||||
} else {
|
||||
console.error('Still no canvas or context available');
|
||||
}
|
||||
},
|
||||
|
||||
async saveDrawing() {
|
||||
|
||||
@@ -1099,6 +1336,7 @@ export default {
|
||||
nextStrokeTargetPosition: this.nextStrokeTargetPosition,
|
||||
exerciseCounter: this.exerciseCounter,
|
||||
additionalStrokes: this.additionalStrokes,
|
||||
code: this.getFullCode ? this.getFullCode() : '',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
@@ -1125,7 +1363,7 @@ export default {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('CourtDrawingTool: Error in saveDrawing:', error);
|
||||
// Fehlerbehandlung
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1288,7 +1526,8 @@ export default {
|
||||
if (this.additionalStrokes.length > 0) {
|
||||
this.additionalStrokes.forEach(stroke => {
|
||||
const strokeNameMap = {
|
||||
'US': 'Schupf', 'OS': 'Konter', 'TS': 'Topspin', 'FL': 'Flip', 'BL': 'Block'
|
||||
'US': 'Schupf', 'OS': 'Konter', 'TS': 'Topspin', 'F': 'Flip', 'B': 'Block',
|
||||
'SCH': 'Schuss', 'SAB': 'Schnittabwehr', 'BAB': 'Ballonabwehr'
|
||||
};
|
||||
const sideNameMap = {
|
||||
'VH': 'Vorhand', 'RH': 'Rückhand'
|
||||
@@ -1310,6 +1549,7 @@ export default {
|
||||
|
||||
addNextStroke() {
|
||||
if (!this.nextStrokeTargetPosition) return;
|
||||
if (this.additionalStrokes.length >= 4) return;
|
||||
|
||||
// Neuen Schlag zur Liste hinzufügen
|
||||
this.additionalStrokes.push({
|
||||
@@ -1321,6 +1561,8 @@ export default {
|
||||
|
||||
// Counter erhöhen
|
||||
this.exerciseCounter++;
|
||||
// Auswahl für nächsten Zusatzschlag zurücksetzen
|
||||
this.nextStrokeTargetPosition = '';
|
||||
|
||||
// Textfelder aktualisieren
|
||||
this.updateTextFields();
|
||||
@@ -1347,7 +1589,8 @@ export default {
|
||||
,
|
||||
emitDrawingData() {
|
||||
const drawingData = {
|
||||
selectedStartPosition: this.selectedStartPosition,
|
||||
selectedStartPosition: this.getEffectiveStartPosition(),
|
||||
selectedCirclePosition: this.resolveStartCirclePosition(),
|
||||
strokeType: this.strokeType,
|
||||
spinType: this.spinType,
|
||||
targetPosition: this.targetPosition,
|
||||
@@ -1355,9 +1598,20 @@ export default {
|
||||
nextStrokeSide: this.nextStrokeSide,
|
||||
nextStrokeTargetPosition: this.nextStrokeTargetPosition,
|
||||
exerciseCounter: this.exerciseCounter,
|
||||
additionalStrokes: this.additionalStrokes
|
||||
additionalStrokes: this.additionalStrokes,
|
||||
code: this.getFullCode ? this.getFullCode() : ''
|
||||
};
|
||||
this.$emit('update-drawing-data', drawingData);
|
||||
},
|
||||
|
||||
getEffectiveStartPosition() {
|
||||
if (this.selectedStartPosition === 'AS1' || this.selectedStartPosition === 'AS2' || this.selectedStartPosition === 'AS3') {
|
||||
return this.selectedStartPosition;
|
||||
}
|
||||
if (this.selectedCirclePosition === 'top') return 'AS1';
|
||||
if (this.selectedCirclePosition === 'middle') return 'AS2';
|
||||
if (this.selectedCirclePosition === 'bottom') return 'AS3';
|
||||
return 'AS2';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1484,6 +1738,14 @@ canvas {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
display: block;
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 40px;
|
||||
height: 30px;
|
||||
@@ -1564,13 +1826,11 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
.next-stroke-type {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.next-stroke-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
@@ -1586,4 +1846,47 @@ input[type="range"] {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Kompakte 3x3 Zielauswahl */
|
||||
.target-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 24px);
|
||||
grid-auto-rows: 24px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.grid-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: #28a745; /* grün, auch wenn nichts ausgewählt ist */
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.grid-btn.is-active {
|
||||
color: #FFD700; /* gelb */
|
||||
font-weight: 700; /* fett */
|
||||
}
|
||||
|
||||
/* Ausgewählte Aufschlag-/Seiten-Buttons: fette gelbe Schrift */
|
||||
.btn-stroke.btn-primary {
|
||||
color: #FFD700 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Ausgewählte Schlagtyp-Buttons: fette gelbe Schrift */
|
||||
.btn-stroke-type.btn-primary {
|
||||
color: #FFD700 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
185
frontend/src/components/CsvImportDialog.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
title="Spielplan importieren"
|
||||
size="small"
|
||||
@close="handleClose"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="import-form">
|
||||
<div class="form-group">
|
||||
<label for="csvFile">CSV-Datei hochladen:</label>
|
||||
<input
|
||||
type="file"
|
||||
id="csvFile"
|
||||
@change="handleFileChange"
|
||||
accept=".csv"
|
||||
required
|
||||
class="file-input"
|
||||
ref="fileInput"
|
||||
/>
|
||||
<div v-if="selectedFile" class="file-info">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">{{ selectedFile.name }}</span>
|
||||
<span class="file-size">({{ formatFileSize(selectedFile.size) }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button @click="handleClose" class="btn-secondary">Abbrechen</button>
|
||||
<button @click="handleSubmit" class="btn-primary" :disabled="!selectedFile">Importieren</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'CsvImportDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'import'],
|
||||
data() {
|
||||
return {
|
||||
selectedFile: null
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.selectedFile = null;
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = '';
|
||||
}
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
handleFileChange(event) {
|
||||
this.selectedFile = event.target.files && event.target.files[0] ? event.target.files[0] : null;
|
||||
},
|
||||
handleSubmit() {
|
||||
if (this.selectedFile) {
|
||||
this.$emit('import', this.selectedFile);
|
||||
}
|
||||
},
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(newVal) {
|
||||
if (!newVal) {
|
||||
// Dialog geschlossen - Reset
|
||||
this.selectedFile = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-input::-webkit-file-upload-button {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.file-input::-webkit-file-upload-button:hover {
|
||||
background: var(--background-light);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
</style>
|
||||
|
||||
351
frontend/src/components/DIALOGS_OVERVIEW.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# Dialog-Komponenten Übersicht
|
||||
|
||||
> Hinweis: Alle Beispiele verwenden unsere Dialog-Komponenten (`InfoDialog`, `ConfirmDialog`). Klassische Browser-Dialoge (`alert`, `confirm`) werden ausschließlich zu Vergleichszwecken genannt und sollen nicht in produktivem Code eingesetzt werden.
|
||||
|
||||
## 📋 Alle verfügbaren Dialog-Komponenten
|
||||
|
||||
### Basis-Komponenten
|
||||
|
||||
#### 1. **BaseDialog.vue**
|
||||
Basis-Template für alle Dialoge (modal und nicht-modal).
|
||||
|
||||
**Props:** `modelValue`, `isModal`, `title`, `size`, `position`, `zIndex`, `closable`, `minimizable`, `draggable`, `closeOnOverlay`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<BaseDialog v-model="isOpen" title="Titel" size="medium">
|
||||
Content
|
||||
</BaseDialog>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Informations-Dialoge
|
||||
|
||||
#### 2. **InfoDialog.vue**
|
||||
Einfache Informationsmeldungen mit OK-Button.
|
||||
|
||||
**Props:** `modelValue`, `title`, `message`, `details`, `type` (info/success/warning/error), `icon`, `okText`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<InfoDialog
|
||||
v-model="showInfo"
|
||||
title="Erfolg"
|
||||
message="Gespeichert!"
|
||||
type="success"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 3. **ConfirmDialog.vue**
|
||||
Bestätigungsdialoge mit OK/Abbrechen.
|
||||
|
||||
**Props:** `modelValue`, `title`, `message`, `details`, `type` (info/warning/danger/success), `confirmText`, `cancelText`, `showCancel`
|
||||
|
||||
**Events:** `@confirm`, `@cancel`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<ConfirmDialog
|
||||
v-model="showConfirm"
|
||||
title="Löschen?"
|
||||
message="Wirklich löschen?"
|
||||
type="danger"
|
||||
@confirm="handleDelete"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bild-Dialoge
|
||||
|
||||
#### 4. **ImageDialog.vue**
|
||||
Einfache Bildanzeige.
|
||||
|
||||
**Props:** `modelValue`, `title`, `imageUrl`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<ImageDialog
|
||||
v-model="showImage"
|
||||
title="Bild"
|
||||
:image-url="imageUrl"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 5. **ImageViewerDialog.vue**
|
||||
Erweiterte Bildanzeige mit Aktionen (Drehen, Zoom).
|
||||
|
||||
**Props:** `modelValue`, `title`, `imageUrl`, `memberId`, `showActions`, `allowRotate`, `allowZoom`
|
||||
|
||||
**Events:** `@rotate`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<ImageViewerDialog
|
||||
v-model="showImage"
|
||||
:image-url="imageUrl"
|
||||
:member-id="memberId"
|
||||
:allow-rotate="true"
|
||||
@rotate="handleRotate"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Spezifische Dialoge
|
||||
|
||||
#### 6. **MemberNotesDialog.vue**
|
||||
Notizen-Verwaltung für Mitglieder mit Bild, Tags und Notizliste.
|
||||
|
||||
**Props:** `modelValue`, `member`, `notes`, `selectedTags`, `availableTags`, `noteContent`
|
||||
|
||||
**Events:** `@add-note`, `@delete-note`, `@add-tag`, `@remove-tag`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<MemberNotesDialog
|
||||
v-model="showNotes"
|
||||
:member="selectedMember"
|
||||
:notes="notes"
|
||||
v-model:note-content="newNote"
|
||||
v-model:selected-tags="tags"
|
||||
:available-tags="allTags"
|
||||
@add-note="addNote"
|
||||
@delete-note="deleteNote"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 7. **TagHistoryDialog.vue**
|
||||
Tag-Historie für Mitglieder.
|
||||
|
||||
**Props:** `modelValue`, `member`, `tagHistory`, `selectedTags`, `activityTags`
|
||||
|
||||
**Events:** `@select-tag`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<TagHistoryDialog
|
||||
v-model="showHistory"
|
||||
:member="member"
|
||||
:tag-history="history"
|
||||
v-model:selected-tags="tags"
|
||||
:activity-tags="allTags"
|
||||
@select-tag="handleSelectTag"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 8. **AccidentFormDialog.vue**
|
||||
Unfall-Meldungs-Formular.
|
||||
|
||||
**Props:** `modelValue`, `accident`, `members`, `participants`, `accidents`
|
||||
|
||||
**Events:** `@submit`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<AccidentFormDialog
|
||||
v-model="showAccident"
|
||||
v-model:accident="accidentData"
|
||||
:members="members"
|
||||
:participants="participants"
|
||||
:accidents="reportedAccidents"
|
||||
@submit="saveAccident"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 9. **QuickAddMemberDialog.vue**
|
||||
Schnelles Hinzufügen von Mitgliedern.
|
||||
|
||||
**Props:** `modelValue`, `member`
|
||||
|
||||
**Events:** `@submit`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<QuickAddMemberDialog
|
||||
v-model="showQuickAdd"
|
||||
v-model:member="newMember"
|
||||
@submit="createMember"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 10. **CsvImportDialog.vue**
|
||||
CSV-Datei-Import mit Dateiauswahl.
|
||||
|
||||
**Props:** `modelValue`
|
||||
|
||||
**Events:** `@import`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<CsvImportDialog
|
||||
v-model="showImport"
|
||||
@import="handleImport"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 11. **TrainingDetailsDialog.vue**
|
||||
Trainings-Details und Statistiken für Mitglieder.
|
||||
|
||||
**Props:** `modelValue`, `member`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<TrainingDetailsDialog
|
||||
v-model="showDetails"
|
||||
:member="selectedMember"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 12. **MemberSelectionDialog.vue**
|
||||
Mitglieder-Auswahl mit Empfehlungen (für PDF-Generierung).
|
||||
|
||||
**Props:** `modelValue`, `members`, `selectedIds`, `activeMemberId`, `recommendations`, `recommendedKeys`, `showRecommendations`
|
||||
|
||||
**Events:** `@select-all`, `@deselect-all`, `@toggle-member`, `@toggle-recommendation`, `@generate-pdf`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<MemberSelectionDialog
|
||||
v-model="showSelection"
|
||||
:members="members"
|
||||
v-model:selected-ids="selectedIds"
|
||||
:recommendations="recommendations"
|
||||
@generate-pdf="generatePdf"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Dialog-Typen und Icons
|
||||
|
||||
| Typ | Icon | Farbe | Verwendung |
|
||||
|-----|------|-------|-----------|
|
||||
| `info` | ℹ️ | Blau | Informationen |
|
||||
| `success` | ✅ | Grün | Erfolgsmeldungen |
|
||||
| `warning` | ⚠️ | Gelb | Warnungen |
|
||||
| `error` | ⛔ | Rot | Fehler |
|
||||
| `danger` | ⛔ | Rot | Gefährliche Aktionen (Löschen) |
|
||||
|
||||
---
|
||||
|
||||
## 📏 Dialog-Größen
|
||||
|
||||
| Größe | Breite | Verwendung |
|
||||
|-------|--------|-----------|
|
||||
| `small` | 400px | Einfache Meldungen, Bestätigungen |
|
||||
| `medium` | 600px | Standard-Formulare, Notizen |
|
||||
| `large` | 900px | Komplexe Inhalte, Listen, Details |
|
||||
| `fullscreen` | 90vw × 90vh | Maximale Fläche |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Composables
|
||||
|
||||
### useDialog()
|
||||
```javascript
|
||||
import { useDialog } from '@/composables/useDialog.js';
|
||||
|
||||
const { isOpen, open, close, toggle } = useDialog();
|
||||
```
|
||||
|
||||
### useConfirm()
|
||||
```javascript
|
||||
import { useConfirm } from '@/composables/useDialog.js';
|
||||
|
||||
const { confirm } = useConfirm();
|
||||
const result = await confirm({
|
||||
title: 'Löschen?',
|
||||
message: 'Wirklich löschen?',
|
||||
type: 'danger'
|
||||
});
|
||||
```
|
||||
|
||||
### useInfo()
|
||||
```javascript
|
||||
import { useInfo } from '@/composables/useDialog.js';
|
||||
|
||||
const { showInfo } = useInfo();
|
||||
await showInfo({
|
||||
title: 'Erfolg',
|
||||
message: 'Gespeichert!',
|
||||
type: 'success'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Migration Guide
|
||||
|
||||
### Von JavaScript-Alert/Confirm:
|
||||
|
||||
**Vorher:**
|
||||
```javascript
|
||||
// Nicht mehr verwenden:
|
||||
// alert('Fehler!');
|
||||
// if (confirm('Löschen?')) { ... }
|
||||
```
|
||||
|
||||
**Nachher:**
|
||||
```javascript
|
||||
// In setup() oder data():
|
||||
const infoDialog = ref({ isOpen: false, title: '', message: '', details: '', type: 'info' });
|
||||
const confirmDialog = ref({ isOpen: false, title: '', message: '', details: '', type: 'info', resolveCallback: null });
|
||||
|
||||
// Methods:
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = { isOpen: true, title, message, details, type };
|
||||
}
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info') {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = { isOpen: true, title, message, details, type, resolveCallback: resolve };
|
||||
});
|
||||
}
|
||||
|
||||
// Verwenden:
|
||||
this.showInfo('Fehler', 'Fehler!', '', 'error');
|
||||
const confirmed = await this.showConfirm('Bestätigung', 'Löschen?', '', 'danger');
|
||||
if (confirmed) { ... }
|
||||
```
|
||||
|
||||
### Von Inline-Modal:
|
||||
|
||||
**Vorher:**
|
||||
```vue
|
||||
<div v-if="showModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<span class="close" @click="showModal = false">×</span>
|
||||
<h3>Titel</h3>
|
||||
<!-- Content -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Nachher:**
|
||||
```vue
|
||||
<BaseDialog v-model="showModal" title="Titel">
|
||||
<!-- Content -->
|
||||
</BaseDialog>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Best Practices
|
||||
|
||||
1. **v-model verwenden** für Sichtbarkeit
|
||||
2. **Slots nutzen** für flexible Inhalte (#footer, #header-actions)
|
||||
3. **Events emittieren** statt direkte Manipulation
|
||||
4. **Props validieren** mit `validator` Funktionen
|
||||
5. **Responsive Design** berücksichtigen
|
||||
6. **Memory Management** bei Blob URLs (revokeObjectURL)
|
||||
7. **Eigene Komponenten** für wiederverwendbare Dialoge erstellen
|
||||
|
||||
---
|
||||
|
||||
## 📚 Weitere Informationen
|
||||
|
||||
Siehe `DIALOG_TEMPLATES.md` für detaillierte API-Dokumentation und erweiterte Beispiele.
|
||||
|
||||
310
frontend/src/components/DIALOG_TEMPLATES.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Dialog-Templates Dokumentation
|
||||
|
||||
## Übersicht
|
||||
|
||||
Das Dialog-System bietet wiederverwendbare Templates für modale und nicht-modale Dialoge.
|
||||
|
||||
## Komponenten
|
||||
|
||||
### 1. BaseDialog.vue
|
||||
|
||||
Die Basis-Komponente für alle Dialoge. Unterstützt sowohl modale als auch nicht-modale Dialoge.
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Typ | Default | Beschreibung |
|
||||
|------|-----|---------|--------------|
|
||||
| `modelValue` | Boolean | `false` | v-model Binding für Sichtbarkeit |
|
||||
| `isModal` | Boolean | `true` | Modaler Dialog (mit Overlay) oder nicht-modal |
|
||||
| `title` | String | - | Dialog-Titel (erforderlich) |
|
||||
| `size` | String | `'medium'` | Größe: `'small'`, `'medium'`, `'large'`, `'fullscreen'` |
|
||||
| `position` | Object | `{ x: 100, y: 100 }` | Position für nicht-modale Dialoge |
|
||||
| `zIndex` | Number | `1000` | z-Index des Dialogs |
|
||||
| `closable` | Boolean | `true` | Schließen-Button anzeigen |
|
||||
| `minimizable` | Boolean | `false` | Minimieren-Button anzeigen |
|
||||
| `draggable` | Boolean | `true` | Dialog verschiebbar (nur nicht-modal) |
|
||||
| `closeOnOverlay` | Boolean | `true` | Bei Klick auf Overlay schließen (nur modal) |
|
||||
|
||||
#### Events
|
||||
|
||||
| Event | Parameter | Beschreibung |
|
||||
|-------|-----------|--------------|
|
||||
| `update:modelValue` | Boolean | Wird beim Öffnen/Schließen gefeuert |
|
||||
| `close` | - | Wird beim Schließen gefeuert |
|
||||
| `minimize` | - | Wird beim Minimieren gefeuert |
|
||||
| `focus` | - | Wird bei Klick auf nicht-modalen Dialog gefeuert |
|
||||
| `update:position` | Object | Neue Position nach Verschieben |
|
||||
|
||||
#### Slots
|
||||
|
||||
| Slot | Beschreibung |
|
||||
|------|--------------|
|
||||
| `default` | Dialog-Inhalt |
|
||||
| `header-actions` | Zusätzliche Aktionen im Header |
|
||||
| `footer` | Dialog-Footer (optional) |
|
||||
|
||||
#### Beispiel: Modaler Dialog
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="isOpen"
|
||||
title="Mein Dialog"
|
||||
size="medium"
|
||||
@close="handleClose"
|
||||
>
|
||||
<p>Dialog-Inhalt hier</p>
|
||||
|
||||
<template #footer>
|
||||
<button @click="isOpen = false">Schließen</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import BaseDialog from '@/components/BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
components: { BaseDialog },
|
||||
setup() {
|
||||
const isOpen = ref(false);
|
||||
|
||||
const handleClose = () => {
|
||||
// Reagiere auf das Schließen des Dialogs
|
||||
};
|
||||
|
||||
return { isOpen, handleClose };
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
#### Beispiel: Nicht-modaler Dialog
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="isOpen"
|
||||
title="Nicht-modaler Dialog"
|
||||
:is-modal="false"
|
||||
:position="position"
|
||||
@update:position="position = $event"
|
||||
:draggable="true"
|
||||
:minimizable="true"
|
||||
size="medium"
|
||||
>
|
||||
<p>Dieser Dialog kann verschoben werden!</p>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive } from 'vue';
|
||||
import BaseDialog from '@/components/BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
components: { BaseDialog },
|
||||
setup() {
|
||||
const isOpen = ref(false);
|
||||
const position = reactive({ x: 100, y: 100 });
|
||||
|
||||
return { isOpen, position };
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## 2. ConfirmDialog.vue
|
||||
|
||||
Spezialisierter Dialog für Bestätigungen.
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Typ | Default | Beschreibung |
|
||||
|------|-----|---------|--------------|
|
||||
| `modelValue` | Boolean | `false` | v-model Binding |
|
||||
| `title` | String | `'Bestätigung'` | Dialog-Titel |
|
||||
| `message` | String | - | Hauptnachricht (erforderlich) |
|
||||
| `details` | String | `''` | Zusätzliche Details |
|
||||
| `type` | String | `'info'` | Typ: `'info'`, `'warning'`, `'danger'`, `'success'` |
|
||||
| `confirmText` | String | `'OK'` | Text für Bestätigen-Button |
|
||||
| `cancelText` | String | `'Abbrechen'` | Text für Abbrechen-Button |
|
||||
| `showCancel` | Boolean | `true` | Abbrechen-Button anzeigen |
|
||||
|
||||
#### Events
|
||||
|
||||
| Event | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `confirm` | Wird bei Bestätigung gefeuert |
|
||||
| `cancel` | Wird bei Abbruch gefeuert |
|
||||
|
||||
#### Beispiel
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ConfirmDialog
|
||||
v-model="showConfirm"
|
||||
title="Löschen bestätigen"
|
||||
message="Möchten Sie diesen Eintrag wirklich löschen?"
|
||||
type="danger"
|
||||
confirm-text="Löschen"
|
||||
@confirm="handleDelete"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
|
||||
export default {
|
||||
components: { ConfirmDialog },
|
||||
setup() {
|
||||
const showConfirm = ref(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
// Lösche den Eintrag
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Brich die Aktion ab
|
||||
};
|
||||
|
||||
return { showConfirm, handleDelete, handleCancel };
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## 3. ImageDialog.vue
|
||||
|
||||
Einfacher Dialog zur Anzeige von Bildern.
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Typ | Default | Beschreibung |
|
||||
|------|-----|---------|--------------|
|
||||
| `modelValue` | Boolean | `false` | v-model Binding |
|
||||
| `title` | String | `'Bild'` | Dialog-Titel |
|
||||
| `imageUrl` | String | `''` | URL des anzuzeigenden Bildes |
|
||||
|
||||
#### Events
|
||||
|
||||
| Event | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `close` | Wird beim Schließen gefeuert |
|
||||
|
||||
#### Beispiel
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ImageDialog
|
||||
v-model="showImage"
|
||||
title="Aktivitätsbild"
|
||||
:image-url="imageUrl"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import ImageDialog from '@/components/ImageDialog.vue';
|
||||
|
||||
export default {
|
||||
components: { ImageDialog },
|
||||
setup() {
|
||||
const showImage = ref(false);
|
||||
const imageUrl = ref('');
|
||||
|
||||
return { showImage, imageUrl };
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## 4. ImageViewerDialog.vue
|
||||
|
||||
Erweiterter Bild-Dialog mit Aktionen (Drehen, Zoom).
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Typ | Default | Beschreibung |
|
||||
|------|-----|---------|--------------|
|
||||
| `modelValue` | Boolean | `false` | v-model Binding |
|
||||
| `title` | String | `'Bild'` | Dialog-Titel |
|
||||
| `imageUrl` | String | `''` | URL des anzuzeigenden Bildes |
|
||||
| `memberId` | Number/String | `null` | ID des zugehörigen Members (optional) |
|
||||
| `showActions` | Boolean | `true` | Aktions-Buttons anzeigen |
|
||||
| `allowRotate` | Boolean | `true` | Drehen-Buttons anzeigen |
|
||||
| `allowZoom` | Boolean | `false` | Zoom-Button anzeigen |
|
||||
|
||||
#### Events
|
||||
|
||||
| Event | Parameter | Beschreibung |
|
||||
|-------|-----------|--------------|
|
||||
| `close` | - | Wird beim Schließen gefeuert |
|
||||
| `rotate` | Object | Wird beim Drehen gefeuert: `{ direction, memberId, rotation }` |
|
||||
|
||||
#### Beispiel
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ImageViewerDialog
|
||||
v-model="showImageModal"
|
||||
title="Mitgliedsbild"
|
||||
:image-url="selectedImageUrl"
|
||||
:member-id="selectedMemberId"
|
||||
:allow-rotate="true"
|
||||
@rotate="handleRotate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import ImageViewerDialog from '@/components/ImageViewerDialog.vue';
|
||||
import apiClient from '@/apiClient.js';
|
||||
|
||||
export default {
|
||||
components: { ImageViewerDialog },
|
||||
setup() {
|
||||
const showImageModal = ref(false);
|
||||
const selectedImageUrl = ref('');
|
||||
const selectedMemberId = ref(null);
|
||||
|
||||
const handleRotate = async (event) => {
|
||||
const { direction, memberId } = event;
|
||||
// API-Aufruf zum Drehen des Bildes
|
||||
await apiClient.post(`/members/${memberId}/rotate`, { direction });
|
||||
};
|
||||
|
||||
return { showImageModal, selectedImageUrl, selectedMemberId, handleRotate };
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## 5. Composables
|
||||
|
||||
### useDialog()
|
||||
|
||||
Einfaches Composable für Dialog-Verwaltung.
|
||||
|
||||
```javascript
|
||||
import { useDialog } from '@/composables/useDialog.js';
|
||||
|
||||
const { isOpen, open, close, toggle } = useDialog();
|
||||
|
||||
// Dialog öffnen
|
||||
open();
|
||||
|
||||
// Dialog schließen
|
||||
close();
|
||||
|
||||
// Dialog umschalten
|
||||
toggle();
|
||||
```
|
||||
|
||||
## useConfirm()
|
||||
|
||||
Promise-basiertes Composable für Bestätigungsdialoge.
|
||||
|
||||
```
|
||||
469
frontend/src/components/DialogExamples.vue
Normal file
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div class="dialog-examples">
|
||||
<h2>Dialog-Beispiele</h2>
|
||||
|
||||
<div class="example-section">
|
||||
<h3>Modale Dialoge</h3>
|
||||
<div class="button-group">
|
||||
<button @click="openSimpleModal" class="btn-primary">Einfacher Modal</button>
|
||||
<button @click="openLargeModal" class="btn-primary">Großer Modal</button>
|
||||
<button @click="openFullscreenModal" class="btn-primary">Fullscreen Modal</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="example-section">
|
||||
<h3>Nicht-modale Dialoge</h3>
|
||||
<div class="button-group">
|
||||
<button @click="openNonModalDialog" class="btn-primary">Nicht-modaler Dialog</button>
|
||||
<button @click="openMultipleDialogs" class="btn-primary">Mehrere Dialoge</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="example-section">
|
||||
<h3>Informations-Dialoge</h3>
|
||||
<div class="button-group">
|
||||
<button @click="showInfoDialog" class="btn-primary">Info</button>
|
||||
<button @click="showSuccessDialog" class="btn-success">Erfolg</button>
|
||||
<button @click="showWarningDialog" class="btn-warning">Warnung</button>
|
||||
<button @click="showErrorDialog" class="btn-danger">Fehler</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="example-section">
|
||||
<h3>Bestätigungs-Dialoge</h3>
|
||||
<div class="button-group">
|
||||
<button @click="showInfoConfirm" class="btn-primary">Info</button>
|
||||
<button @click="showWarningConfirm" class="btn-warning">Warnung</button>
|
||||
<button @click="showDangerConfirm" class="btn-danger">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="example-section">
|
||||
<h3>Composable-Verwendung</h3>
|
||||
<div class="button-group">
|
||||
<button @click="composableDialog.open()" class="btn-primary">useDialog Beispiel</button>
|
||||
<button @click="showComposableConfirm" class="btn-primary">useConfirm Beispiel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Einfacher Modal Dialog -->
|
||||
<BaseDialog
|
||||
v-model="simpleModal.isOpen"
|
||||
title="Einfacher Modal Dialog"
|
||||
size="medium"
|
||||
>
|
||||
<p>Dies ist ein einfacher modaler Dialog mit mittlerer Größe.</p>
|
||||
<p>Klicken Sie außerhalb oder auf das X, um zu schließen.</p>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Großer Modal Dialog -->
|
||||
<BaseDialog
|
||||
v-model="largeModal.isOpen"
|
||||
title="Großer Modal Dialog"
|
||||
size="large"
|
||||
>
|
||||
<p>Dies ist ein großer modaler Dialog.</p>
|
||||
<p>Er bietet mehr Platz für Inhalte.</p>
|
||||
<div style="height: 400px; background: #f5f5f5; margin-top: 1rem; padding: 1rem;">
|
||||
Scroll-Bereich für viel Inhalt...
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Fullscreen Modal Dialog -->
|
||||
<BaseDialog
|
||||
v-model="fullscreenModal.isOpen"
|
||||
title="Fullscreen Modal Dialog"
|
||||
size="fullscreen"
|
||||
>
|
||||
<p>Dies ist ein Fullscreen-Dialog.</p>
|
||||
<p>Er nimmt fast den gesamten Bildschirm ein (90vw x 90vh).</p>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Nicht-modaler Dialog -->
|
||||
<BaseDialog
|
||||
v-model="nonModal.isOpen"
|
||||
title="Nicht-modaler Dialog"
|
||||
:is-modal="false"
|
||||
:position="nonModal.position"
|
||||
@update:position="nonModal.position = $event"
|
||||
size="medium"
|
||||
:draggable="true"
|
||||
:minimizable="true"
|
||||
@minimize="handleMinimize('nonModal')"
|
||||
>
|
||||
<p>Dies ist ein nicht-modaler Dialog.</p>
|
||||
<p>Sie können ihn verschieben und mehrere gleichzeitig öffnen!</p>
|
||||
<template #footer>
|
||||
<button @click="nonModal.isOpen = false" class="btn-secondary">Schließen</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Zweiter nicht-modaler Dialog -->
|
||||
<BaseDialog
|
||||
v-model="nonModal2.isOpen"
|
||||
title="Zweiter nicht-modaler Dialog"
|
||||
:is-modal="false"
|
||||
:position="nonModal2.position"
|
||||
@update:position="nonModal2.position = $event"
|
||||
size="small"
|
||||
:draggable="true"
|
||||
:z-index="1001"
|
||||
>
|
||||
<p>Noch ein nicht-modaler Dialog!</p>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Informations-Dialoge -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
title="Information"
|
||||
message="Dies ist eine Informationsmeldung."
|
||||
type="info"
|
||||
@ok="infoDialog.isOpen = false"
|
||||
/>
|
||||
|
||||
<InfoDialog
|
||||
v-model="successDialog.isOpen"
|
||||
title="Erfolg"
|
||||
message="Der Vorgang wurde erfolgreich abgeschlossen!"
|
||||
details="Alle Änderungen wurden gespeichert."
|
||||
type="success"
|
||||
@ok="successDialog.isOpen = false"
|
||||
/>
|
||||
|
||||
<InfoDialog
|
||||
v-model="warningDialog.isOpen"
|
||||
title="Warnung"
|
||||
message="Bitte beachten Sie folgende Hinweise."
|
||||
details="Einige Felder sind möglicherweise nicht vollständig ausgefüllt."
|
||||
type="warning"
|
||||
@ok="warningDialog.isOpen = false"
|
||||
/>
|
||||
|
||||
<InfoDialog
|
||||
v-model="errorDialog.isOpen"
|
||||
title="Fehler"
|
||||
message="Ein Fehler ist aufgetreten."
|
||||
details="Bitte versuchen Sie es später erneut."
|
||||
type="error"
|
||||
@ok="errorDialog.isOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Bestätigungs-Dialoge -->
|
||||
<ConfirmDialog
|
||||
v-model="infoConfirm.isOpen"
|
||||
title="Information"
|
||||
message="Dies ist eine Informationsmeldung."
|
||||
type="info"
|
||||
:show-cancel="false"
|
||||
@confirm="infoConfirm.isOpen = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="warningConfirm.isOpen"
|
||||
title="Warnung"
|
||||
message="Sind Sie sicher, dass Sie fortfahren möchten?"
|
||||
details="Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
type="warning"
|
||||
@confirm="handleWarningConfirm"
|
||||
@cancel="warningConfirm.isOpen = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="dangerConfirm.isOpen"
|
||||
title="Löschen bestätigen"
|
||||
message="Möchten Sie diesen Eintrag wirklich löschen?"
|
||||
type="danger"
|
||||
confirm-text="Löschen"
|
||||
@confirm="handleDelete"
|
||||
@cancel="dangerConfirm.isOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Composable Dialog -->
|
||||
<BaseDialog
|
||||
v-model="composableDialog.isOpen"
|
||||
title="Dialog mit useDialog Composable"
|
||||
size="medium"
|
||||
>
|
||||
<p>Dieser Dialog verwendet das useDialog Composable.</p>
|
||||
<p>Das macht die Verwaltung einfacher!</p>
|
||||
<template #footer>
|
||||
<button @click="composableDialog.close()" class="btn-primary">Schließen</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Composable Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmComposable.isOpen"
|
||||
:title="confirmComposable.config.title"
|
||||
:message="confirmComposable.config.message"
|
||||
:type="confirmComposable.config.type"
|
||||
@confirm="confirmComposable.handleConfirm"
|
||||
@cancel="confirmComposable.handleCancel"
|
||||
/>
|
||||
|
||||
<!-- Minimierte Dialoge Anzeige -->
|
||||
<div v-if="minimizedDialogs.length > 0" class="minimized-section">
|
||||
<h4>Minimierte Dialoge:</h4>
|
||||
<button
|
||||
v-for="(dialog, index) in minimizedDialogs"
|
||||
:key="index"
|
||||
@click="restoreDialog(dialog)"
|
||||
class="minimized-btn"
|
||||
>
|
||||
{{ dialog }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { reactive, ref } from 'vue';
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
import ConfirmDialog from './ConfirmDialog.vue';
|
||||
import InfoDialog from './InfoDialog.vue';
|
||||
import { useDialog, useConfirm } from '../composables/useDialog.js';
|
||||
|
||||
export default {
|
||||
name: 'DialogExamples',
|
||||
components: {
|
||||
BaseDialog,
|
||||
ConfirmDialog,
|
||||
InfoDialog
|
||||
},
|
||||
setup() {
|
||||
// Modale Dialoge
|
||||
const simpleModal = reactive({ isOpen: false });
|
||||
const largeModal = reactive({ isOpen: false });
|
||||
const fullscreenModal = reactive({ isOpen: false });
|
||||
|
||||
// Nicht-modale Dialoge
|
||||
const nonModal = reactive({
|
||||
isOpen: false,
|
||||
position: { x: 100, y: 100 }
|
||||
});
|
||||
|
||||
const nonModal2 = reactive({
|
||||
isOpen: false,
|
||||
position: { x: 600, y: 150 }
|
||||
});
|
||||
|
||||
// Informations-Dialoge
|
||||
const infoDialog = reactive({ isOpen: false });
|
||||
const successDialog = reactive({ isOpen: false });
|
||||
const warningDialog = reactive({ isOpen: false });
|
||||
const errorDialog = reactive({ isOpen: false });
|
||||
|
||||
// Bestätigungs-Dialoge
|
||||
const infoConfirm = reactive({ isOpen: false });
|
||||
const warningConfirm = reactive({ isOpen: false });
|
||||
const dangerConfirm = reactive({ isOpen: false });
|
||||
|
||||
// Composables
|
||||
const composableDialog = useDialog();
|
||||
const confirmComposable = useConfirm();
|
||||
|
||||
// Minimierte Dialoge
|
||||
const minimizedDialogs = ref([]);
|
||||
|
||||
return {
|
||||
simpleModal,
|
||||
largeModal,
|
||||
fullscreenModal,
|
||||
nonModal,
|
||||
nonModal2,
|
||||
infoDialog,
|
||||
successDialog,
|
||||
warningDialog,
|
||||
errorDialog,
|
||||
infoConfirm,
|
||||
warningConfirm,
|
||||
dangerConfirm,
|
||||
composableDialog,
|
||||
confirmComposable,
|
||||
minimizedDialogs
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
openSimpleModal() {
|
||||
this.simpleModal.isOpen = true;
|
||||
},
|
||||
|
||||
openLargeModal() {
|
||||
this.largeModal.isOpen = true;
|
||||
},
|
||||
|
||||
openFullscreenModal() {
|
||||
this.fullscreenModal.isOpen = true;
|
||||
},
|
||||
|
||||
openNonModalDialog() {
|
||||
this.nonModal.isOpen = true;
|
||||
},
|
||||
|
||||
openMultipleDialogs() {
|
||||
this.nonModal.isOpen = true;
|
||||
this.nonModal2.isOpen = true;
|
||||
},
|
||||
|
||||
showInfoConfirm() {
|
||||
this.infoConfirm.isOpen = true;
|
||||
},
|
||||
|
||||
showWarningConfirm() {
|
||||
this.warningConfirm.isOpen = true;
|
||||
},
|
||||
|
||||
showDangerConfirm() {
|
||||
this.dangerConfirm.isOpen = true;
|
||||
},
|
||||
|
||||
async showComposableConfirm() {
|
||||
const result = await this.confirmComposable.confirm({
|
||||
title: 'useConfirm Beispiel',
|
||||
message: 'Möchten Sie fortfahren?',
|
||||
details: 'Dies ist ein Beispiel für das useConfirm Composable.',
|
||||
type: 'info'
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Hier kann ein nutzerfreundlicher Hinweis (z. B. Toast) implementiert werden
|
||||
} else {
|
||||
// Hier kann ein nutzerfreundlicher Hinweis (z. B. Toast) implementiert werden
|
||||
}
|
||||
},
|
||||
|
||||
handleWarningConfirm() {
|
||||
// Hier kann ein nutzerfreundlicher Hinweis (z. B. Toast) implementiert werden
|
||||
this.warningConfirm.isOpen = false;
|
||||
},
|
||||
|
||||
handleDelete() {
|
||||
// Hier kann nach erfolgreicher Aktion ein Hinweis angezeigt werden
|
||||
this.dangerConfirm.isOpen = false;
|
||||
},
|
||||
|
||||
handleMinimize(dialogName) {
|
||||
this.minimizedDialogs.push(dialogName);
|
||||
this[dialogName].isOpen = false;
|
||||
},
|
||||
|
||||
restoreDialog(dialogName) {
|
||||
this[dialogName].isOpen = true;
|
||||
const index = this.minimizedDialogs.indexOf(dialogName);
|
||||
if (index > -1) {
|
||||
this.minimizedDialogs.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-examples {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.example-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.example-section h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-warning,
|
||||
.btn-danger {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.minimized-section {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.minimized-section h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.minimized-btn {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.minimized-btn:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -121,6 +121,15 @@ export default {
|
||||
console.log('📤 PostMessage an http://localhost:3000 gesendet');
|
||||
} catch (e) {
|
||||
console.log('PostMessage Fehler (nicht kritisch):', e.message);
|
||||
// Fallback: Versuche mit anderen Origins
|
||||
const origins = ['https://ttde-apps.liga.nu', 'https://liga.nu', '*'];
|
||||
origins.forEach(origin => {
|
||||
try {
|
||||
iframe.contentWindow.postMessage(message, origin);
|
||||
} catch (e2) {
|
||||
// Ignoriere fehlgeschlagene PostMessage-Versuche
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -129,22 +138,18 @@ export default {
|
||||
console.log('- Origin:', event.origin);
|
||||
console.log('- Data:', event.data);
|
||||
|
||||
// Nur Nachrichten von unserem Proxy verarbeiten
|
||||
if (event.origin !== 'http://localhost:3000') {
|
||||
// Nur Nachrichten von unserem Proxy oder nuscore verarbeiten
|
||||
const allowedOrigins = ['http://localhost:3000', 'https://ttde-apps.liga.nu', 'https://liga.nu'];
|
||||
if (!allowedOrigins.includes(event.origin)) {
|
||||
console.log('🚫 Nachricht von unbekannter Origin ignoriert');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hier können wir auf Antworten von nuscore reagieren
|
||||
if (event.data && event.data.action) {
|
||||
console.log('🎯 Action empfangen:', event.data.action);
|
||||
|
||||
if (event.data.action === 'pinFilled') {
|
||||
console.log('✅ PIN wurde erfolgreich eingefügt');
|
||||
} else if (event.data.action === 'pinError') {
|
||||
console.log('❌ Fehler beim PIN-Einfügen:', event.data.error);
|
||||
}
|
||||
if (!event.data || typeof event.data !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Weitere Aktionen bei Bedarf implementieren
|
||||
}
|
||||
},
|
||||
|
||||
@@ -249,13 +254,13 @@ export default {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, rgba(160, 112, 64, 0.95), rgba(128, 75, 41, 0.95));
|
||||
padding: 4px 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 2000;
|
||||
pointer-events: auto;
|
||||
min-height: 40px;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
125
frontend/src/components/ImageDialog.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="title"
|
||||
:is-modal="true"
|
||||
size="large"
|
||||
:closable="true"
|
||||
:close-on-overlay="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Image Content -->
|
||||
<div class="image-dialog-content">
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
:alt="title"
|
||||
class="dialog-image"
|
||||
/>
|
||||
<div v-else class="no-image">
|
||||
Kein Bild verfügbar
|
||||
</div>
|
||||
|
||||
<!-- Optionale zusätzliche Inhalte -->
|
||||
<div v-if="$slots.default" class="image-extra-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer mit optionalen Aktionen -->
|
||||
<template #footer>
|
||||
<slot name="actions">
|
||||
<button @click="handleClose" class="btn-secondary">
|
||||
Schließen
|
||||
</button>
|
||||
</slot>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'ImageDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Bild'
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close'],
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dialog-image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.no-image {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.image-extra-content {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.dialog-image {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
512
frontend/src/components/ImageViewerDialog.vue
Normal file
@@ -0,0 +1,512 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="title"
|
||||
:is-modal="true"
|
||||
size="large"
|
||||
:closable="true"
|
||||
:close-on-overlay="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="image-viewer-content">
|
||||
<div class="image-main" :class="{ 'has-images': hasImages }">
|
||||
<button
|
||||
v-if="hasImages && images.length > 1"
|
||||
class="nav-button nav-button--prev"
|
||||
@click="showPreviousImage"
|
||||
title="Vorheriges Bild"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<div class="image-container">
|
||||
<img
|
||||
v-if="currentImageUrl"
|
||||
:src="currentImageUrl"
|
||||
:alt="title"
|
||||
class="viewer-image"
|
||||
:style="imageStyle"
|
||||
/>
|
||||
<div v-else class="no-image">
|
||||
Kein Bild verfügbar
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasImages && images.length > 1"
|
||||
class="nav-button nav-button--next"
|
||||
@click="showNextImage"
|
||||
title="Nächstes Bild"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showActions && hasImages" class="image-actions">
|
||||
<button
|
||||
v-if="allowRotate && currentImageId"
|
||||
@click="rotate('left')"
|
||||
class="action-btn"
|
||||
title="90° links drehen"
|
||||
>
|
||||
↺ Links drehen
|
||||
</button>
|
||||
<button
|
||||
v-if="allowRotate && currentImageId"
|
||||
@click="rotate('right')"
|
||||
class="action-btn"
|
||||
title="90° rechts drehen"
|
||||
>
|
||||
↻ Rechts drehen
|
||||
</button>
|
||||
<button
|
||||
v-if="showSetPrimary && currentImage && !currentImage.isPrimary"
|
||||
@click="setPrimary"
|
||||
class="action-btn"
|
||||
title="Als Hauptbild festlegen"
|
||||
>
|
||||
⭐ Als Hauptbild setzen
|
||||
</button>
|
||||
<button
|
||||
v-if="showDelete && currentImageId"
|
||||
@click="deleteImage"
|
||||
class="action-btn action-btn--danger"
|
||||
title="Bild löschen"
|
||||
>
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="allowUpload" class="upload-section">
|
||||
<label class="upload-label">
|
||||
📤 Bilder hochladen
|
||||
<input type="file" multiple accept="image/*" @change="handleFileSelect" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="hasImages" class="thumbnail-strip">
|
||||
<div
|
||||
v-for="image in images"
|
||||
:key="image.id"
|
||||
class="thumbnail"
|
||||
:class="{
|
||||
'thumbnail--active': image.id === currentImageId,
|
||||
'thumbnail--primary': image.isPrimary
|
||||
}"
|
||||
@click="selectImage(image.id)"
|
||||
>
|
||||
<img :src="image.objectUrl || image.url" :alt="`Bild #${image.id}`" />
|
||||
<span v-if="image.isPrimary" class="thumbnail-badge">Primär</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.default" class="extra-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<slot name="footer">
|
||||
<button @click="handleClose" class="btn-secondary">
|
||||
Schließen
|
||||
</button>
|
||||
</slot>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'ImageViewerDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Bild'
|
||||
},
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activeImageId: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
},
|
||||
memberId: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
allowRotate: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
allowZoom: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showSetPrimary: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowUpload: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
'update:activeImageId',
|
||||
'close',
|
||||
'rotate',
|
||||
'delete-image',
|
||||
'set-primary',
|
||||
'upload-images'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
scale: 1,
|
||||
currentImageId: this.activeImageId
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasImages() {
|
||||
return Array.isArray(this.images) && this.images.length > 0;
|
||||
},
|
||||
currentImage() {
|
||||
if (!this.hasImages) {
|
||||
return null;
|
||||
}
|
||||
const match = this.images.find(img => img.id === this.currentImageId);
|
||||
return match || this.images[0] || null;
|
||||
},
|
||||
currentImageUrl() {
|
||||
if (this.currentImage) {
|
||||
return this.currentImage.objectUrl || this.currentImage.url || '';
|
||||
}
|
||||
return this.imageUrl || '';
|
||||
},
|
||||
imageStyle() {
|
||||
return {
|
||||
transform: `scale(${this.scale})`
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
activeImageId(newVal) {
|
||||
this.currentImageId = newVal;
|
||||
},
|
||||
images: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
if (!this.hasImages) {
|
||||
this.currentImageId = null;
|
||||
return;
|
||||
}
|
||||
if (!this.currentImageId || !this.images.some(img => img.id === this.currentImageId)) {
|
||||
this.currentImageId = this.images[0].id;
|
||||
this.emitActiveChange();
|
||||
}
|
||||
}
|
||||
},
|
||||
modelValue(newVal) {
|
||||
if (!newVal) {
|
||||
this.scale = 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.scale = 1;
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
selectImage(imageId) {
|
||||
if (!imageId || imageId === this.currentImageId) return;
|
||||
this.currentImageId = imageId;
|
||||
this.emitActiveChange();
|
||||
},
|
||||
emitActiveChange() {
|
||||
this.$emit('update:activeImageId', this.currentImageId);
|
||||
},
|
||||
showPreviousImage() {
|
||||
if (!this.hasImages || this.images.length <= 1) return;
|
||||
const currentIndex = this.images.findIndex(img => img.id === this.currentImageId);
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : this.images.length - 1;
|
||||
this.currentImageId = this.images[prevIndex].id;
|
||||
this.emitActiveChange();
|
||||
},
|
||||
showNextImage() {
|
||||
if (!this.hasImages || this.images.length <= 1) return;
|
||||
const currentIndex = this.images.findIndex(img => img.id === this.currentImageId);
|
||||
const nextIndex = currentIndex === -1 || currentIndex === this.images.length - 1 ? 0 : currentIndex + 1;
|
||||
this.currentImageId = this.images[nextIndex].id;
|
||||
this.emitActiveChange();
|
||||
},
|
||||
rotate(direction) {
|
||||
if (!this.allowRotate || !this.currentImageId) return;
|
||||
this.$emit('rotate', {
|
||||
direction,
|
||||
memberId: this.memberId,
|
||||
imageId: this.currentImageId
|
||||
});
|
||||
},
|
||||
deleteImage() {
|
||||
if (!this.showDelete || !this.currentImageId) return;
|
||||
this.$emit('delete-image', {
|
||||
memberId: this.memberId,
|
||||
imageId: this.currentImageId
|
||||
});
|
||||
},
|
||||
setPrimary() {
|
||||
if (!this.showSetPrimary || !this.currentImageId) return;
|
||||
this.$emit('set-primary', {
|
||||
memberId: this.memberId,
|
||||
imageId: this.currentImageId
|
||||
});
|
||||
},
|
||||
handleFileSelect(event) {
|
||||
const files = event?.target?.files;
|
||||
if (!this.allowUpload || !files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.$emit('upload-images', {
|
||||
memberId: this.memberId,
|
||||
files
|
||||
});
|
||||
event.target.value = '';
|
||||
},
|
||||
resetZoom() {
|
||||
this.scale = 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-viewer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.image-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-main.has-images {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 220px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewer-image {
|
||||
max-width: 100%;
|
||||
max-height: 45vh;
|
||||
object-fit: contain;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.no-image {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--primary-light);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.action-btn--danger {
|
||||
border-color: #dc3545;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.action-btn--danger:hover {
|
||||
background: #dc354514;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-label {
|
||||
position: relative;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
font-size: 0.95rem;
|
||||
transition: border 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-label:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.upload-label input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.thumbnail-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border: 2px solid transparent;
|
||||
transition: transform 0.2s ease, border 0.2s ease;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbnail--active {
|
||||
border-color: var(--primary-color);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.thumbnail--primary {
|
||||
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.5);
|
||||
}
|
||||
|
||||
.thumbnail-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: rgba(40, 167, 69, 0.85);
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.extra-content {
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.viewer-image {
|
||||
max-height: 35vh;
|
||||
}
|
||||
|
||||
.image-main {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
229
frontend/src/components/InfoDialog.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="title"
|
||||
:is-modal="true"
|
||||
:size="size"
|
||||
:closable="true"
|
||||
:close-on-overlay="closeOnOverlay"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="info-content">
|
||||
<div v-if="icon" class="info-icon" :class="`icon-${type}`">
|
||||
{{ computedIcon }}
|
||||
</div>
|
||||
<div class="info-message">
|
||||
{{ message }}
|
||||
</div>
|
||||
<div v-if="details" class="info-details">
|
||||
{{ details }}
|
||||
</div>
|
||||
<div v-if="$slots.default" class="info-extra">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer mit OK-Button -->
|
||||
<template #footer>
|
||||
<button
|
||||
@click="handleOk"
|
||||
:class="buttonClass"
|
||||
>
|
||||
{{ okText }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'InfoDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Information'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
details: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
validator: (value) => ['info', 'success', 'warning', 'error'].includes(value)
|
||||
},
|
||||
icon: {
|
||||
type: [String, Boolean],
|
||||
default: true
|
||||
},
|
||||
okText: {
|
||||
type: String,
|
||||
default: 'OK'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'small'
|
||||
},
|
||||
closeOnOverlay: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
computedIcon() {
|
||||
// Wenn ein eigenes Icon übergeben wurde
|
||||
if (typeof this.icon === 'string') {
|
||||
return this.icon;
|
||||
}
|
||||
|
||||
// Wenn icon=false, kein Icon
|
||||
if (this.icon === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard-Icons je nach Typ
|
||||
const icons = {
|
||||
info: 'ℹ️',
|
||||
success: '✅',
|
||||
warning: '⚠️',
|
||||
error: '⛔'
|
||||
};
|
||||
return icons[this.type] || icons.info;
|
||||
},
|
||||
buttonClass() {
|
||||
const classes = {
|
||||
info: 'btn-primary',
|
||||
success: 'btn-success',
|
||||
warning: 'btn-warning',
|
||||
error: 'btn-danger'
|
||||
};
|
||||
return classes[this.type] || 'btn-primary';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleOk() {
|
||||
this.$emit('ok');
|
||||
this.handleClose();
|
||||
},
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.info-content {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.icon-info {
|
||||
color: #17a2b8;
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.icon-error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.info-message {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.info-details {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-extra {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-success,
|
||||
.btn-warning,
|
||||
.btn-danger {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -101,20 +101,15 @@ export default {
|
||||
console.log('PostMessage gesendet mit Code:', this.match.code);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Fehler beim Senden der PostMessage:', error);
|
||||
}
|
||||
},
|
||||
|
||||
startUrlMonitoring() {
|
||||
console.log('🔍 Starte URL-Überwachung für iframe');
|
||||
console.log('💡 Hinweis: PIN-Einfügung funktioniert am besten nach der Code-Eingabe und Weiterleitung');
|
||||
console.log('📋 Verwenden Sie den "📌 PIN einfügen" Button nach der Weiterleitung zur Meeting-Seite');
|
||||
|
||||
// Einfache Überwachung ohne Cross-Origin-Zugriff
|
||||
this.urlCheckInterval = setInterval(() => {
|
||||
const iframe = this.$refs.reportIframe;
|
||||
if (iframe) {
|
||||
console.log('🔗 Iframe aktiv, bereit für PIN-Einfügung');
|
||||
// Iframe aktiv
|
||||
}
|
||||
}, 10000); // Alle 10 Sekunden
|
||||
|
||||
@@ -122,7 +117,6 @@ export default {
|
||||
setTimeout(() => {
|
||||
if (this.urlCheckInterval) {
|
||||
clearInterval(this.urlCheckInterval);
|
||||
console.log('⏰ URL-Überwachung beendet (Timeout)');
|
||||
}
|
||||
}, 60000);
|
||||
},
|
||||
@@ -133,11 +127,8 @@ export default {
|
||||
},
|
||||
|
||||
attemptPinInsertionAfterRedirect() {
|
||||
console.log('🎯 Versuche PIN-Einfügung (manuell ausgelöst)');
|
||||
|
||||
const iframe = this.$refs.reportIframe;
|
||||
if (!iframe || !this.match.homePin) {
|
||||
console.log('❌ Iframe oder PIN nicht verfügbar');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,16 +141,20 @@ export default {
|
||||
};
|
||||
console.log('📤 Sende PostMessage:', message);
|
||||
try {
|
||||
iframe.contentWindow.postMessage(message, 'https://ttde-apps.liga.nu');
|
||||
iframe.contentWindow.postMessage(message, 'http://localhost:3000');
|
||||
} catch (e) {
|
||||
console.log('PostMessage Fehler (nicht kritisch):', e.message);
|
||||
iframe.contentWindow.postMessage(message, '*');
|
||||
// Fallback: Versuche mit wildcard origin
|
||||
try {
|
||||
iframe.contentWindow.postMessage(message, '*');
|
||||
} catch (e2) {
|
||||
console.log('PostMessage Fallback fehlgeschlagen:', e2.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Methode, die vom DialogManager aufgerufen werden kann
|
||||
insertPinManually() {
|
||||
console.log('🎯 PIN-Einfügung manuell ausgelöst');
|
||||
this.attemptPinInsertionAfterRedirect();
|
||||
},
|
||||
|
||||
@@ -188,11 +183,9 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('PostMessage empfangen:', event.data);
|
||||
|
||||
// Hier können wir auf Antworten von nuscore reagieren
|
||||
if (event.data.action === 'codeFilled') {
|
||||
console.log('Code wurde erfolgreich eingefügt');
|
||||
// Code erfolgreich eingefügt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<button @click="insertPin" class="header-action-btn" title="PIN automatisch einfügen">
|
||||
📌 PIN einfügen
|
||||
</button>
|
||||
<button @click="copyPin" class="header-action-btn copy-button" title="PIN in Zwischenablage kopieren">
|
||||
<button @click="copyPin($event)" class="header-action-btn copy-button" title="PIN in Zwischenablage kopieren">
|
||||
📋 PIN kopieren
|
||||
</button>
|
||||
</div>
|
||||
@@ -32,27 +32,21 @@ export default {
|
||||
},
|
||||
|
||||
async copyPin(event) {
|
||||
const button = event?.target;
|
||||
const pin = this.match.homePin || this.match.guestPin;
|
||||
if (!pin) {
|
||||
console.warn('⚠️ Keine PIN verfügbar zum Kopieren');
|
||||
if (button) {
|
||||
this.showCopyFeedback(button, 'Keine PIN verfügbar', '#dc3545');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(pin);
|
||||
console.log('✅ PIN erfolgreich kopiert:', pin);
|
||||
|
||||
// Visuelles Feedback
|
||||
const button = event?.target;
|
||||
const originalText = button.textContent;
|
||||
button.textContent = '✅ Kopiert!';
|
||||
button.style.backgroundColor = '#28a745';
|
||||
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.style.backgroundColor = '';
|
||||
}, 2000);
|
||||
|
||||
if (button) {
|
||||
this.showCopyFeedback(button, '✅ Kopiert!', '#28a745');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Kopieren der PIN:', error);
|
||||
|
||||
@@ -64,8 +58,22 @@ export default {
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
console.log('✅ PIN über Fallback kopiert:', pin);
|
||||
if (button) {
|
||||
this.showCopyFeedback(button, '✅ Kopiert!', '#28a745');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
showCopyFeedback(button, text, backgroundColor) {
|
||||
const originalText = button.textContent;
|
||||
const originalColor = button.style.backgroundColor;
|
||||
button.textContent = text;
|
||||
button.style.backgroundColor = backgroundColor;
|
||||
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.style.backgroundColor = originalColor;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
318
frontend/src/components/MemberActivitiesDialog.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="isOpen"
|
||||
:title="`Übungen von ${memberName}`"
|
||||
@close="closeDialog"
|
||||
:width="800"
|
||||
>
|
||||
<div class="activities-content">
|
||||
<!-- Period Selection -->
|
||||
<div class="period-selector">
|
||||
<label>Zeitraum:</label>
|
||||
<select v-model="selectedPeriod" @change="loadActivities">
|
||||
<option value="month">Letzte 4 Wochen</option>
|
||||
<option value="3months">Letzte 3 Monate</option>
|
||||
<option value="6months">Letztes halbes Jahr</option>
|
||||
<option value="year">Letztes Jahr</option>
|
||||
<option value="all">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="loading">
|
||||
Lade Übungen...
|
||||
</div>
|
||||
|
||||
<!-- No Activities -->
|
||||
<div v-else-if="activities.length === 0" class="no-activities">
|
||||
Keine Übungen im gewählten Zeitraum gefunden.
|
||||
</div>
|
||||
|
||||
<!-- Activities List -->
|
||||
<div v-else class="activities-list">
|
||||
<table class="activities-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Übung</th>
|
||||
<th>Häufigkeit</th>
|
||||
<th>Daten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="activity in activities" :key="activity.name">
|
||||
<td class="activity-name">{{ activity.name }}</td>
|
||||
<td class="activity-count">{{ activity.count }}x</td>
|
||||
<td class="activity-dates">
|
||||
<div class="dates-container">
|
||||
<span
|
||||
v-for="(date, index) in activity.dates.slice(0, showAllDates[activity.name] ? undefined : 5)"
|
||||
:key="index"
|
||||
class="date-badge"
|
||||
>
|
||||
{{ formatDate(date) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="activity.dates.length > 5 && !showAllDates[activity.name]"
|
||||
@click="toggleShowAllDates(activity.name)"
|
||||
class="show-more-btn"
|
||||
>
|
||||
+{{ activity.dates.length - 5 }} weitere
|
||||
</button>
|
||||
<button
|
||||
v-if="activity.dates.length > 5 && showAllDates[activity.name]"
|
||||
@click="toggleShowAllDates(activity.name)"
|
||||
class="show-less-btn"
|
||||
>
|
||||
weniger anzeigen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<button @click="closeDialog" class="btn-close">Schließen</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
import apiClient from '../apiClient.js';
|
||||
|
||||
export default {
|
||||
name: 'MemberActivitiesDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
clubId: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const memberName = computed(() => {
|
||||
if (!props.member) return '';
|
||||
return `${props.member.firstName} ${props.member.lastName}`;
|
||||
});
|
||||
|
||||
const selectedPeriod = ref('6months');
|
||||
const loading = ref(false);
|
||||
const activities = ref([]);
|
||||
const showAllDates = ref({});
|
||||
|
||||
const loadActivities = async () => {
|
||||
if (!props.member) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/member-activities/${props.clubId}/${props.member.id}`,
|
||||
{
|
||||
params: {
|
||||
period: selectedPeriod.value
|
||||
}
|
||||
}
|
||||
);
|
||||
activities.value = response.data;
|
||||
// Reset showAllDates
|
||||
showAllDates.value = {};
|
||||
} catch (error) {
|
||||
console.error('Error loading member activities:', error);
|
||||
activities.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(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}`;
|
||||
};
|
||||
|
||||
const toggleShowAllDates = (activityName) => {
|
||||
showAllDates.value[activityName] = !showAllDates.value[activityName];
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
// Watch for dialog open to load activities
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue && props.member) {
|
||||
loadActivities();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
memberName,
|
||||
selectedPeriod,
|
||||
loading,
|
||||
activities,
|
||||
showAllDates,
|
||||
loadActivities,
|
||||
formatDate,
|
||||
toggleShowAllDates,
|
||||
closeDialog
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activities-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.period-selector label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #2c3e50);
|
||||
}
|
||||
|
||||
.period-selector select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.no-activities {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.activities-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.activities-table thead th {
|
||||
background-color: var(--header-bg, #f5f5f5);
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--border-color, #ddd);
|
||||
}
|
||||
|
||||
.activities-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
}
|
||||
|
||||
.activities-table tbody tr:hover {
|
||||
background-color: var(--row-hover, #f9f9f9);
|
||||
}
|
||||
|
||||
.activities-table td {
|
||||
padding: 0.75rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.activity-name {
|
||||
font-weight: 500;
|
||||
color: var(--primary-color, #2c3e50);
|
||||
}
|
||||
|
||||
.activity-count {
|
||||
font-weight: 600;
|
||||
color: var(--accent-color, #3498db);
|
||||
text-align: center;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.activity-dates {
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.dates-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--badge-bg, #e8f4f8);
|
||||
color: var(--badge-text, #2980b9);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.show-more-btn,
|
||||
.show-less-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: transparent;
|
||||
color: var(--link-color, #3498db);
|
||||
border: 1px solid var(--link-color, #3498db);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.show-more-btn:hover,
|
||||
.show-less-btn:hover {
|
||||
background-color: var(--link-color, #3498db);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background-color: var(--secondary-color, #95a5a6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background-color: var(--secondary-color-dark, #7f8c8d);
|
||||
}
|
||||
</style>
|
||||
|
||||
152
frontend/src/components/MemberActivityStatsDialog.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="`Übungs-Statistiken ${member ? member.firstName + ' ' + member.lastName : ''}`"
|
||||
size="medium"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="activity-stats-content">
|
||||
<!-- Letzte 3 Teilnahmen -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Letzte 3 Teilnahmen</h3>
|
||||
<div v-if="lastParticipations && lastParticipations.length" class="participations-list">
|
||||
<div v-for="participation in lastParticipations" :key="participation.id" class="participation-item">
|
||||
<div class="participation-name">{{ participation.activityName }}</div>
|
||||
<div class="participation-date">{{ formatDate(participation.date) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
<em>Keine Teilnahmen vorhanden</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistik der Übungen -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Statistik der Übungen</h3>
|
||||
<div v-if="activityStats && activityStats.length" class="stats-list">
|
||||
<div v-for="stat in activityStats" :key="stat.name" class="stat-item">
|
||||
<div class="stat-name">{{ stat.name }}</div>
|
||||
<div class="stat-count">{{ stat.count }}x</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
<em>Keine Statistiken vorhanden</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'MemberActivityStatsDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
lastParticipations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activityStats: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close'],
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-stats-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: var(--primary-color);
|
||||
margin: 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--background-light);
|
||||
}
|
||||
|
||||
.participations-list,
|
||||
.stats-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.participation-item,
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.participation-item:hover,
|
||||
.stat-item:hover {
|
||||
background: var(--background-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.participation-name,
|
||||
.stat-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.participation-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-count {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
356
frontend/src/components/MemberGalleryDialog.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="date && date !== 'new' ? 'Mitglieder-Galerie - Klicken Sie auf ein Bild, um als Teilnehmer hinzuzufügen' : 'Mitglieder-Galerie'"
|
||||
size="large"
|
||||
:close-on-overlay="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="gallery-dialog-content">
|
||||
<div class="gallery-controls">
|
||||
<label for="gallery-size">Bildgröße:</label>
|
||||
<select id="gallery-size" v-model="gallerySize" @change="loadGalleryMembers" :disabled="galleryLoading">
|
||||
<option :value="100">100x100 px</option>
|
||||
<option :value="150">150x150 px</option>
|
||||
<option :value="200">200x200 px</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="galleryLoading" class="gallery-loading">Galerie wird geladen…</div>
|
||||
<div v-else-if="galleryMembers.length > 0" class="gallery-members-grid" :style="{ gridTemplateColumns: 'repeat(auto-fill, ' + gallerySize + 'px)' }">
|
||||
<div
|
||||
v-for="member in galleryMembers"
|
||||
:key="member.memberId"
|
||||
class="gallery-member-item"
|
||||
:class="{ 'is-participant': date && date !== 'new' && isParticipant(member.memberId) }"
|
||||
:style="{ width: gallerySize + 'px', minWidth: gallerySize + 'px', height: gallerySize + 'px' }"
|
||||
@click="handleGalleryMemberClick(member)"
|
||||
>
|
||||
<img
|
||||
v-if="member.imageUrl"
|
||||
:src="member.imageUrl"
|
||||
:alt="member.fullName"
|
||||
class="gallery-member-image"
|
||||
@error="handleImageError(member)"
|
||||
@load="handleImageLoad(member)"
|
||||
/>
|
||||
<div v-else class="gallery-member-placeholder">Kein Bild</div>
|
||||
<div class="gallery-member-name">{{ member.fullName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="gallery-error">
|
||||
{{ galleryError || 'Keine Mitglieder mit Bildern gefunden.' }}
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
import apiClient from '../apiClient.js';
|
||||
|
||||
export default {
|
||||
name: 'MemberGalleryDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentClub: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
date: {
|
||||
type: [Object, String],
|
||||
default: null
|
||||
},
|
||||
isParticipant: {
|
||||
type: Function,
|
||||
default: () => false
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'member-click'],
|
||||
data() {
|
||||
return {
|
||||
galleryLoading: false,
|
||||
galleryImageUrl: null,
|
||||
galleryError: '',
|
||||
gallerySize: 200,
|
||||
galleryMembers: []
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
modelValue(newVal) {
|
||||
if (newVal) {
|
||||
this.loadGalleryMembers();
|
||||
} else {
|
||||
this.handleClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateGallerySizeBasedOnCount() {
|
||||
const count = this.galleryMembers.length;
|
||||
if (count < 11) {
|
||||
this.gallerySize = 200;
|
||||
} else if (count < 22) {
|
||||
this.gallerySize = 150;
|
||||
} else {
|
||||
this.gallerySize = 100;
|
||||
}
|
||||
},
|
||||
async loadGalleryMembers() {
|
||||
if (!this.currentClub || this.galleryLoading) {
|
||||
return;
|
||||
}
|
||||
this.galleryLoading = true;
|
||||
this.galleryError = '';
|
||||
this.galleryImageUrl = null;
|
||||
this.revokeGalleryImage();
|
||||
try {
|
||||
const response = await apiClient.get(`/clubmembers/gallery/${this.currentClub}?format=json&size=${this.gallerySize}`);
|
||||
const members = response.data.members || [];
|
||||
|
||||
// Setze Größe basierend auf Anzahl der Mitglieder (vor dem Laden der Bilder)
|
||||
this.galleryMembers = members; // Temporär setzen für count
|
||||
this.updateGallerySizeBasedOnCount();
|
||||
|
||||
// Lade Bilder als Blobs und erstelle ObjectURLs
|
||||
this.galleryMembers = await Promise.all(members.map(async (member) => {
|
||||
try {
|
||||
const imageUrl = await this.loadMemberImageAsBlob(member.memberId);
|
||||
console.log(`[loadGalleryMembers] Member ${member.memberId} (${member.fullName}): imageUrl =`, imageUrl ? 'OK' : 'null');
|
||||
return {
|
||||
...member,
|
||||
imageUrl: imageUrl || null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[loadGalleryMembers] Fehler beim Laden des Bildes für Mitglied ${member.memberId}:`, error);
|
||||
return {
|
||||
...member,
|
||||
imageUrl: null
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
// Sortiere nach Vorname, dann Nachname
|
||||
this.galleryMembers.sort((a, b) => {
|
||||
const firstNameA = (a.firstName || '').toLowerCase();
|
||||
const firstNameB = (b.firstName || '').toLowerCase();
|
||||
const lastNameA = (a.lastName || '').toLowerCase();
|
||||
const lastNameB = (b.lastName || '').toLowerCase();
|
||||
|
||||
if (firstNameA !== firstNameB) {
|
||||
return firstNameA.localeCompare(firstNameB);
|
||||
}
|
||||
return lastNameA.localeCompare(lastNameB);
|
||||
});
|
||||
|
||||
console.log('[loadGalleryMembers] Geladene Mitglieder:', this.galleryMembers.map(m => ({ id: m.memberId, name: m.fullName, hasImage: !!m.imageUrl })));
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Galerie:', error);
|
||||
this.galleryError = error?.response?.data?.error || 'Galerie konnte nicht geladen werden.';
|
||||
this.galleryMembers = [];
|
||||
} finally {
|
||||
this.galleryLoading = false;
|
||||
}
|
||||
},
|
||||
async loadMemberImageAsBlob(memberId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/clubmembers/image/${this.currentClub}/${memberId}`, { responseType: 'blob' });
|
||||
if (!response.data || response.data.size === 0) {
|
||||
console.warn(`[loadMemberImageAsBlob] Leeres Blob für Mitglied ${memberId}`);
|
||||
return null;
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(response.data);
|
||||
console.log(`[loadMemberImageAsBlob] ObjectURL erstellt für Mitglied ${memberId}:`, objectUrl.substring(0, 50) + '...');
|
||||
return objectUrl;
|
||||
} catch (error) {
|
||||
// 404 ist OK - Mitglied hat kein Bild
|
||||
if (error?.response?.status === 404) {
|
||||
console.log(`[loadMemberImageAsBlob] Kein Bild für Mitglied ${memberId} (404)`);
|
||||
return null;
|
||||
}
|
||||
console.error(`[loadMemberImageAsBlob] Fehler beim Laden des Bildes für Mitglied ${memberId}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
revokeGalleryImage() {
|
||||
if (this.galleryImageUrl) {
|
||||
URL.revokeObjectURL(this.galleryImageUrl);
|
||||
this.galleryImageUrl = null;
|
||||
}
|
||||
},
|
||||
handleClose() {
|
||||
this.revokeGalleryImage();
|
||||
// Revoke all object URLs
|
||||
this.galleryMembers.forEach(member => {
|
||||
if (member.imageUrl) {
|
||||
URL.revokeObjectURL(member.imageUrl);
|
||||
}
|
||||
});
|
||||
this.galleryMembers = [];
|
||||
this.$emit('update:modelValue', false);
|
||||
},
|
||||
handleGalleryMemberClick(member) {
|
||||
this.$emit('member-click', member);
|
||||
},
|
||||
handleImageError(member) {
|
||||
console.error(`[handleImageError] Bild konnte nicht geladen werden für Mitglied ${member.memberId} (${member.fullName}), URL:`, member.imageUrl);
|
||||
// Setze imageUrl auf null, damit der Placeholder angezeigt wird
|
||||
member.imageUrl = null;
|
||||
},
|
||||
handleImageLoad(member) {
|
||||
console.log(`[handleImageLoad] Bild erfolgreich geladen für Mitglied ${member.memberId} (${member.fullName})`);
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.handleClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gallery-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
padding: 0;
|
||||
min-height: 60vh;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.gallery-controls {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color, #ddd);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #f9f9f9;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.gallery-controls label {
|
||||
font-weight: 500;
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.gallery-controls select {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gallery-controls select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.gallery-loading,
|
||||
.gallery-error {
|
||||
font-size: 1rem;
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.gallery-members-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, max-content);
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-items: start;
|
||||
box-sizing: border-box;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.gallery-member-item {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
transition: all 0.2s ease;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gallery-member-item:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.gallery-member-item.is-participant {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.gallery-member-image {
|
||||
object-fit: cover;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
pointer-events: none; /* Prevent image from blocking clicks on parent */
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.gallery-member-name {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
color: #ff6b6b;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba(0, 0, 0, 0.5) !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.gallery-member-item.is-participant .gallery-member-name {
|
||||
color: #51cf66;
|
||||
}
|
||||
|
||||
.gallery-member-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 0;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
249
frontend/src/components/MemberNotesDialog.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="`Notizen für ${member ? member.firstName + ' ' + member.lastName : ''}`"
|
||||
size="large"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="member" class="notes-modal-content">
|
||||
<div class="notes-header-info">
|
||||
Telefon-Nr.: {{ member.phone }}
|
||||
</div>
|
||||
<div class="notes-body">
|
||||
<div class="notes-left">
|
||||
<img v-if="member.imageUrl" :src="member.imageUrl" alt="Mitgliedsbild"
|
||||
class="member-image" />
|
||||
</div>
|
||||
<div class="notes-right">
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<multiselect
|
||||
v-model="localSelectedTags"
|
||||
:options="availableTags"
|
||||
placeholder="Tags auswählen"
|
||||
label="name"
|
||||
track-by="id"
|
||||
multiple
|
||||
:close-on-select="false"
|
||||
@tag="$emit('add-tag', $event)"
|
||||
@remove="$emit('remove-tag', $event)"
|
||||
:allow-empty="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Neue Notiz</label>
|
||||
<textarea v-model="localNoteContent" placeholder="Neue Notiz" rows="4" class="note-textarea"></textarea>
|
||||
<button @click="handleAddNote" class="btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
<div class="notes-list">
|
||||
<h4>Notizen</h4>
|
||||
<ul>
|
||||
<li v-for="note in notes" :key="note.id" class="note-item">
|
||||
<button @click="$emit('delete-note', note.id)" class="trash-btn">🗑️</button>
|
||||
<span class="note-content">{{ note.content }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
|
||||
export default {
|
||||
name: 'MemberNotesDialog',
|
||||
components: {
|
||||
BaseDialog,
|
||||
Multiselect
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
notes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedTags: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
availableTags: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
noteContent: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'add-note', 'delete-note', 'add-tag', 'remove-tag', 'update:noteContent', 'update:selectedTags'],
|
||||
data() {
|
||||
return {
|
||||
localNoteContent: this.noteContent,
|
||||
localSelectedTags: this.selectedTags
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
noteContent(newVal) {
|
||||
this.localNoteContent = newVal;
|
||||
},
|
||||
selectedTags(newVal) {
|
||||
this.localSelectedTags = newVal;
|
||||
},
|
||||
localNoteContent(newVal) {
|
||||
this.$emit('update:noteContent', newVal);
|
||||
},
|
||||
localSelectedTags(newVal) {
|
||||
this.$emit('update:selectedTags', newVal);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
handleAddNote() {
|
||||
this.$emit('add-note', this.localNoteContent);
|
||||
this.localNoteContent = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notes-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.notes-header-info {
|
||||
padding: 0.5rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.notes-body {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.notes-left {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-image {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.notes-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.note-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.notes-list h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.notes-list ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.note-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trash-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
padding: 0.25rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.trash-btn:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
align-self: flex-start;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.notes-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.member-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
260
frontend/src/components/MemberSelectionDialog.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
title="Mitglieder auswählen"
|
||||
size="large"
|
||||
:close-on-overlay="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="member-selection-content">
|
||||
<div class="controls-bar">
|
||||
<button class="btn-secondary" @click="$emit('select-all')">Alle auswählen</button>
|
||||
<button class="btn-secondary" @click="$emit('deselect-all')">Alle abwählen</button>
|
||||
</div>
|
||||
|
||||
<div class="selection-layout">
|
||||
<div class="members-column">
|
||||
<h4>Mitglieder</h4>
|
||||
<div class="checkbox-list">
|
||||
<label v-for="m in members" :key="m.id" class="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="m.id"
|
||||
:checked="selectedIds.includes(m.id)"
|
||||
@change="handleMemberToggle(m.id, $event.target.checked)"
|
||||
/>
|
||||
<span :class="{ active: activeMemberId === m.id }">
|
||||
{{ m.firstName }} {{ m.lastName }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recommendations-column" v-if="activeMember && showRecommendations">
|
||||
<h4>Empfehlungen</h4>
|
||||
<div v-if="recommendations && recommendations.length" class="checkbox-list">
|
||||
<label v-for="rec in recommendations" :key="rec.key" class="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isRecommended(rec.key)"
|
||||
@change="handleRecommendationToggle(rec.key, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ rec.name }} — {{ rec.date }} {{ rec.time }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
<em>Keine passenden Empfehlungen gefunden.</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<button class="btn-secondary" @click="handleClose">Schließen</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="selectedIds.length === 0"
|
||||
@click="$emit('generate-pdf')"
|
||||
>
|
||||
PDF erzeugen
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'MemberSelectionDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
members: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedIds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activeMemberId: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
},
|
||||
recommendations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
recommendedKeys: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
showRecommendations: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
'close',
|
||||
'select-all',
|
||||
'deselect-all',
|
||||
'toggle-member',
|
||||
'toggle-recommendation',
|
||||
'generate-pdf',
|
||||
'update:selectedIds',
|
||||
'update:activeMemberId'
|
||||
],
|
||||
computed: {
|
||||
activeMember() {
|
||||
return this.members.find(m => m.id === this.activeMemberId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
handleMemberToggle(memberId, checked) {
|
||||
this.$emit('update:activeMemberId', memberId);
|
||||
this.$emit('toggle-member', { memberId, checked });
|
||||
},
|
||||
handleRecommendationToggle(key, checked) {
|
||||
this.$emit('toggle-recommendation', { memberId: this.activeMemberId, key, checked });
|
||||
},
|
||||
isRecommended(key) {
|
||||
return this.recommendedKeys.includes(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.member-selection-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.selection-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.members-column,
|
||||
.recommendations-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.members-column h4,
|
||||
.recommendations-column h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.checkbox-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 500px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.checkbox-item:hover {
|
||||
background: var(--background-light);
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-item span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox-item span.active {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.no-data {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.selection-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.checkbox-list {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
712
frontend/src/components/MemberTransferDialog.vue
Normal file
@@ -0,0 +1,712 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
title="Mitglieder übertragen"
|
||||
size="large"
|
||||
:close-on-overlay="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="loadingConfig" class="loading-config">
|
||||
Gespeicherte Konfiguration wird geladen...
|
||||
</div>
|
||||
|
||||
<div v-else class="transfer-form">
|
||||
<div v-if="!hasConfig" class="config-missing">
|
||||
<p><strong>Keine Konfiguration gefunden</strong></p>
|
||||
<p>Bitte konfigurieren Sie zuerst die Mitgliederübertragung in den Einstellungen.</p>
|
||||
<router-link to="/member-transfer-settings" class="btn-link" @click="handleClose">
|
||||
Zu den Einstellungen
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="config-summary">
|
||||
<h4>Übertragungskonfiguration</h4>
|
||||
<div class="summary-info">
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Server:</span>
|
||||
<span class="summary-value">{{ config.server }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Endpoint:</span>
|
||||
<span class="summary-value">{{ config.transferEndpoint }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Methode:</span>
|
||||
<span class="summary-value">{{ config.transferMethod }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Format:</span>
|
||||
<span class="summary-value">{{ config.transferFormat }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Modus:</span>
|
||||
<span class="summary-value">{{ config.useBulkMode ? 'Bulk-Import' : 'Einzeln' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<router-link to="/member-transfer-settings" class="btn-link-small" @click="handleClose">
|
||||
Konfiguration bearbeiten
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="config.loginEndpoint" class="form-section">
|
||||
<h4>Login-Daten (optional überschreiben)</h4>
|
||||
<p class="section-hint">Die Login-Daten werden aus den Einstellungen verwendet. Sie können sie hier überschreiben, falls nötig.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Login-Daten:</label>
|
||||
<div class="credentials-group">
|
||||
<div class="credential-row">
|
||||
<!-- Dummy-Felder, um Browser-Autofill zu verhindern (ausgeblendet) -->
|
||||
<input type="text" style="position: absolute; left: -9999px; opacity: 0;" tabindex="-1" autocomplete="off" />
|
||||
<input type="password" style="position: absolute; left: -9999px; opacity: 0;" tabindex="-1" autocomplete="off" />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
v-model="loginCredentials.username"
|
||||
placeholder="Benutzername / Email"
|
||||
class="form-input"
|
||||
autocomplete="off"
|
||||
name="transfer-username"
|
||||
id="transfer-username"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
v-model="loginCredentials.password"
|
||||
placeholder="Passwort (leer lassen für gespeichertes)"
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
name="transfer-password"
|
||||
id="transfer-password"
|
||||
/>
|
||||
</div>
|
||||
<div class="credential-row">
|
||||
<input
|
||||
type="text"
|
||||
v-model="loginCredentials.additionalField1"
|
||||
:placeholder="additionalField1Placeholder"
|
||||
class="form-input"
|
||||
autocomplete="off"
|
||||
name="transfer-additional1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
v-model="loginCredentials.additionalField2"
|
||||
:placeholder="additionalField2Placeholder"
|
||||
class="form-input"
|
||||
autocomplete="off"
|
||||
name="transfer-additional2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint">Nur ausfüllen, wenn Sie die gespeicherten Login-Daten überschreiben möchten. Leere Felder verwenden die gespeicherten Werte.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<button class="btn-secondary" @click="handleClose">Abbrechen</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
@click="handleTransfer"
|
||||
:disabled="!isValid || isTransferring"
|
||||
>
|
||||
{{ isTransferring ? 'Übertrage...' : 'Übertragen' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
import apiClient from '../apiClient.js';
|
||||
import { getSafeErrorMessage, getSafeMessage, sanitizeText } from '../utils/errorMessages.js';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'MemberTransferDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['currentClub']),
|
||||
|
||||
isValid() {
|
||||
return this.hasConfig && !!(this.config.server && this.config.transferEndpoint && this.config.transferTemplate);
|
||||
},
|
||||
|
||||
hasConfig() {
|
||||
return !!(this.config.server && this.config.transferEndpoint && this.config.transferTemplate);
|
||||
},
|
||||
|
||||
additionalField1Placeholder() {
|
||||
if (this.config.loginFormat === 'form-data' || this.config.loginFormat === 'x-www-form-urlencoded') {
|
||||
return 'Feldname: Wert (z.B. client_id: abc123)';
|
||||
}
|
||||
return 'Zusätzliches Feld (z.B. client_id)';
|
||||
},
|
||||
|
||||
additionalField2Placeholder() {
|
||||
if (this.config.loginFormat === 'form-data' || this.config.loginFormat === 'x-www-form-urlencoded') {
|
||||
return 'Feldname: Wert (z.B. client_secret: xyz789)';
|
||||
}
|
||||
return 'Zusätzliches Feld (z.B. client_secret)';
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: {
|
||||
server: '',
|
||||
loginEndpoint: '',
|
||||
loginFormat: 'json',
|
||||
transferEndpoint: '',
|
||||
transferMethod: 'POST',
|
||||
transferFormat: 'json',
|
||||
transferTemplate: '',
|
||||
useBulkMode: false,
|
||||
bulkWrapperTemplate: ''
|
||||
},
|
||||
loginCredentials: {
|
||||
username: '',
|
||||
password: '',
|
||||
additionalField1: '',
|
||||
additionalField2: ''
|
||||
},
|
||||
isTransferring: false,
|
||||
loadingConfig: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
modelValue(newVal) {
|
||||
if (newVal) {
|
||||
// WICHTIG: Felder sofort leeren, bevor Konfiguration geladen wird
|
||||
// Verwende $nextTick, um sicherzustellen, dass Vue die Änderungen verarbeitet
|
||||
this.$nextTick(() => {
|
||||
this.loginCredentials = {
|
||||
username: '',
|
||||
password: '',
|
||||
additionalField1: '',
|
||||
additionalField2: ''
|
||||
};
|
||||
});
|
||||
this.loadSavedConfig();
|
||||
} else {
|
||||
// Beim Schließen auch leeren
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadSavedConfig() {
|
||||
if (!this.currentClub) {
|
||||
// Login-Credentials leeren, auch wenn kein Club vorhanden
|
||||
this.loginCredentials = {
|
||||
username: '',
|
||||
password: '',
|
||||
additionalField1: '',
|
||||
additionalField2: ''
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// WICHTIG: Login-Credentials sofort leeren, damit sie nicht vorausgefüllt werden
|
||||
// Verwende $nextTick, um sicherzustellen, dass Vue die Änderungen verarbeitet
|
||||
this.$nextTick(() => {
|
||||
this.loginCredentials = {
|
||||
username: '',
|
||||
password: '',
|
||||
additionalField1: '',
|
||||
additionalField2: ''
|
||||
};
|
||||
});
|
||||
|
||||
this.loadingConfig = true;
|
||||
try {
|
||||
const response = await apiClient.get(`/member-transfer-config/${this.currentClub}`);
|
||||
|
||||
if (response.data.success && response.data.config) {
|
||||
const savedConfig = response.data.config;
|
||||
|
||||
// Konfiguration aus gespeicherten Daten laden
|
||||
this.config.server = savedConfig.server || '';
|
||||
this.config.loginEndpoint = savedConfig.loginEndpoint || '';
|
||||
this.config.loginFormat = savedConfig.loginFormat || 'json';
|
||||
this.config.transferEndpoint = savedConfig.transferEndpoint || '';
|
||||
this.config.transferMethod = savedConfig.transferMethod || 'POST';
|
||||
this.config.transferFormat = savedConfig.transferFormat || 'json';
|
||||
this.config.transferTemplate = savedConfig.transferTemplate || '';
|
||||
this.config.useBulkMode = savedConfig.useBulkMode || false;
|
||||
this.config.bulkWrapperTemplate = savedConfig.bulkWrapperTemplate || '';
|
||||
|
||||
// Login-Credentials bleiben leer (werden nur verwendet, wenn im Dialog eingegeben)
|
||||
// Gespeicherte Credentials werden vom Backend verwendet, wenn keine eingegeben werden
|
||||
}
|
||||
} catch (error) {
|
||||
// Keine Konfiguration vorhanden - das ist OK, Dialog bleibt leer
|
||||
if (error.response?.status !== 404) {
|
||||
console.error('Fehler beim Laden der gespeicherten Konfiguration:', error);
|
||||
}
|
||||
} finally {
|
||||
// Sicherstellen, dass Login-Credentials leer sind
|
||||
// Verwende $nextTick, um sicherzustellen, dass Vue die Änderungen verarbeitet
|
||||
this.$nextTick(() => {
|
||||
this.loginCredentials = {
|
||||
username: '',
|
||||
password: '',
|
||||
additionalField1: '',
|
||||
additionalField2: ''
|
||||
};
|
||||
});
|
||||
this.loadingConfig = false;
|
||||
}
|
||||
},
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.resetForm();
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.config = {
|
||||
server: '',
|
||||
loginEndpoint: '',
|
||||
loginFormat: 'json',
|
||||
transferEndpoint: '',
|
||||
transferMethod: 'POST',
|
||||
transferFormat: 'json',
|
||||
transferTemplate: '',
|
||||
useBulkMode: false,
|
||||
bulkWrapperTemplate: ''
|
||||
};
|
||||
this.loginCredentials = {
|
||||
username: '',
|
||||
password: '',
|
||||
additionalField1: '',
|
||||
additionalField2: ''
|
||||
};
|
||||
this.isTransferring = false;
|
||||
},
|
||||
|
||||
async handleTransfer() {
|
||||
if (!this.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isTransferring = true;
|
||||
|
||||
try {
|
||||
// Login-Credentials zusammenstellen
|
||||
// WICHTIG: Nur nicht-leere Werte hinzufügen (nach Trimmen)
|
||||
// Leere Strings würden die gespeicherten Credentials überschreiben
|
||||
const loginCredentials = {};
|
||||
const trimmedUsername = this.loginCredentials.username?.trim();
|
||||
const trimmedPassword = this.loginCredentials.password?.trim();
|
||||
|
||||
if (trimmedUsername) {
|
||||
loginCredentials.username = trimmedUsername;
|
||||
}
|
||||
if (trimmedPassword) {
|
||||
loginCredentials.password = trimmedPassword;
|
||||
}
|
||||
|
||||
// Zusätzliche Felder verarbeiten (nur wenn nicht leer)
|
||||
const trimmedAdditional1 = this.loginCredentials.additionalField1?.trim();
|
||||
if (trimmedAdditional1) {
|
||||
const parts1 = trimmedAdditional1.split(':').map(s => s.trim());
|
||||
if (parts1.length === 2 && parts1[0] && parts1[1]) {
|
||||
loginCredentials[parts1[0]] = parts1[1];
|
||||
} else if (parts1.length === 1 && parts1[0]) {
|
||||
// Falls kein Doppelpunkt, verwende den gesamten Wert als Feldname
|
||||
loginCredentials[parts1[0]] = parts1[0];
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedAdditional2 = this.loginCredentials.additionalField2?.trim();
|
||||
if (trimmedAdditional2) {
|
||||
const parts2 = trimmedAdditional2.split(':').map(s => s.trim());
|
||||
if (parts2.length === 2 && parts2[0] && parts2[1]) {
|
||||
loginCredentials[parts2[0]] = parts2[1];
|
||||
} else if (parts2.length === 1 && parts2[0]) {
|
||||
loginCredentials[parts2[0]] = parts2[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Konfiguration zusammenstellen
|
||||
const transferConfig = {
|
||||
transferEndpoint: this.config.transferEndpoint,
|
||||
transferMethod: this.config.transferMethod,
|
||||
transferFormat: this.config.transferFormat,
|
||||
transferTemplate: this.config.transferTemplate,
|
||||
useBulkMode: this.config.useBulkMode,
|
||||
bulkWrapperTemplate: this.config.bulkWrapperTemplate || null
|
||||
};
|
||||
|
||||
// Login-Konfiguration nur hinzufügen, wenn Endpoint vorhanden
|
||||
if (this.config.loginEndpoint) {
|
||||
transferConfig.loginEndpoint = this.config.loginEndpoint;
|
||||
transferConfig.loginFormat = this.config.loginFormat;
|
||||
|
||||
// Nur Login-Credentials hinzufügen, wenn welche eingegeben wurden
|
||||
// Wenn keine eingegeben wurden, werden die gespeicherten verwendet (Backend holt diese)
|
||||
if (Object.keys(loginCredentials).length > 0) {
|
||||
transferConfig.loginCredentials = loginCredentials;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/clubmembers/transfer/${this.currentClub}`,
|
||||
transferConfig
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
const message = getSafeMessage(
|
||||
response.data.message,
|
||||
`${response.data.transferred} von ${response.data.total} Mitgliedern erfolgreich übertragen.`
|
||||
);
|
||||
|
||||
let details = '';
|
||||
|
||||
// Zeige ausgeschlossene Mitglieder an
|
||||
if (response.data.invalidMembers && response.data.invalidMembers.length > 0) {
|
||||
details += 'Ausgeschlossene Mitglieder (fehlende Pflichtfelder):\n';
|
||||
details += response.data.invalidMembers.map(inv => {
|
||||
const name = sanitizeText(`${inv.member.firstName || ''} ${inv.member.lastName || ''}`.trim(), `ID: ${inv.member.id}`);
|
||||
const errorText = sanitizeText(inv.errors.join(', '), 'Unbekannter Fehler');
|
||||
return `${name}: ${errorText}`;
|
||||
}).join('\n');
|
||||
details += '\n\n';
|
||||
}
|
||||
|
||||
// Zeige weitere Fehler an
|
||||
if (response.data.errors && response.data.errors.length > 0) {
|
||||
details += 'Weitere Fehler:\n';
|
||||
details += response.data.errors.map((e) => {
|
||||
const memberName = sanitizeText(e.member, 'Unbekanntes Mitglied');
|
||||
const err = sanitizeText(e.error, 'Unbekannter Fehler');
|
||||
return `${memberName}: ${err}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
this.$emit('success', {
|
||||
message: message,
|
||||
details: details.trim(),
|
||||
results: response.data
|
||||
});
|
||||
|
||||
this.handleClose();
|
||||
} else {
|
||||
// Bei Fehlern auch ausgeschlossene Mitglieder anzeigen
|
||||
let errorDetails = getSafeMessage(response.data.error, 'Übertragung fehlgeschlagen');
|
||||
|
||||
if (response.data.invalidMembers && response.data.invalidMembers.length > 0) {
|
||||
errorDetails += '\n\nAusgeschlossene Mitglieder (fehlende Pflichtfelder):\n';
|
||||
errorDetails += response.data.invalidMembers.map(inv => {
|
||||
const name = sanitizeText(`${inv.member.firstName || ''} ${inv.member.lastName || ''}`.trim(), `ID: ${inv.member.id}`);
|
||||
const err = sanitizeText(inv.errors.join(', '), 'Unbekannter Fehler');
|
||||
return `${name}: ${err}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
this.$emit('error', {
|
||||
message: getSafeMessage(response.data.message, 'Übertragung fehlgeschlagen'),
|
||||
error: errorDetails
|
||||
});
|
||||
|
||||
this.handleClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Transfer error:', error);
|
||||
const errorMessage = getSafeErrorMessage(error, 'Fehler bei der Übertragung');
|
||||
|
||||
this.$emit('error', {
|
||||
message: 'Fehler bei der Übertragung',
|
||||
error: errorMessage
|
||||
});
|
||||
|
||||
this.handleClose();
|
||||
} finally {
|
||||
this.isTransferring = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transfer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.form-section h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group .required {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-input[readonly] {
|
||||
background-color: #f8f9fa;
|
||||
cursor: not-allowed;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
font-family: 'Courier New', monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.credentials-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.credential-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.template-help {
|
||||
margin-top: 0.75rem;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e9ecef;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.template-help strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.template-help ul {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.template-help li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.template-help code {
|
||||
background-color: #e9ecef;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.template-help pre {
|
||||
background-color: #282c34;
|
||||
color: #abb2bf;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.bulk-hint {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #007bff;
|
||||
border-radius: 4px;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.bulk-hint code {
|
||||
background-color: #cce5ff;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.loading-config {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.config-missing {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 6px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.config-missing p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.config-summary {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.config-summary h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.summary-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
color: #333;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.btn-link-small {
|
||||
display: inline-block;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-link-small:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.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;
|
||||
border: none;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -41,6 +41,24 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="formData.autoUpdateRatings"
|
||||
:disabled="!formData.savePassword"
|
||||
/>
|
||||
<span>Automatische Update-Ratings aktivieren</span>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Täglich um 6:00 Uhr werden automatisch die neuesten Ratings von myTischtennis abgerufen.
|
||||
<strong>Erfordert gespeichertes Passwort.</strong>
|
||||
</p>
|
||||
<p v-if="!formData.savePassword" class="warning">
|
||||
⚠️ Für automatische Updates muss das myTischtennis-Passwort gespeichert werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="formData.password">
|
||||
<label for="app-password">Ihr App-Passwort zur Bestätigung:</label>
|
||||
<input
|
||||
@@ -90,6 +108,7 @@ export default {
|
||||
email: this.account?.email || '',
|
||||
password: '',
|
||||
savePassword: this.account?.savePassword || false,
|
||||
autoUpdateRatings: this.account?.autoUpdateRatings || false,
|
||||
userPassword: ''
|
||||
},
|
||||
saving: false,
|
||||
@@ -108,6 +127,11 @@ export default {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Automatische Updates erfordern gespeichertes Passwort
|
||||
if (this.formData.autoUpdateRatings && !this.formData.savePassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
},
|
||||
@@ -121,7 +145,8 @@ export default {
|
||||
try {
|
||||
const payload = {
|
||||
email: this.formData.email,
|
||||
savePassword: this.formData.savePassword
|
||||
savePassword: this.formData.savePassword,
|
||||
autoUpdateRatings: this.formData.autoUpdateRatings
|
||||
};
|
||||
|
||||
// Nur password und userPassword hinzufügen, wenn ein Passwort eingegeben wurde
|
||||
@@ -243,6 +268,13 @@ export default {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.75rem;
|
||||
background-color: #f8d7da;
|
||||
|
||||
228
frontend/src/components/MyTischtennisHistoryDialog.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Update-Ratings History</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div v-if="loading" class="loading">
|
||||
Lade History...
|
||||
</div>
|
||||
|
||||
<div v-else-if="history.length === 0" class="no-history">
|
||||
<p>Noch keine automatischen Updates durchgeführt.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="history-list">
|
||||
<div v-for="entry in history" :key="entry.id" class="history-entry">
|
||||
<div class="history-header">
|
||||
<span class="history-date">{{ formatDate(entry.createdAt) }}</span>
|
||||
<span class="history-status" :class="entry.success ? 'success' : 'error'">
|
||||
{{ entry.success ? 'Erfolgreich' : 'Fehlgeschlagen' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="entry.message" class="history-message">
|
||||
{{ entry.message }}
|
||||
</div>
|
||||
<div v-if="entry.errorDetails" class="history-error">
|
||||
{{ entry.errorDetails }}
|
||||
</div>
|
||||
<div v-if="entry.updatedCount !== undefined" class="history-stats">
|
||||
{{ entry.updatedCount }} Ratings aktualisiert
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="$emit('close')">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
|
||||
export default {
|
||||
name: 'MyTischtennisHistoryDialog',
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
history: []
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadHistory();
|
||||
},
|
||||
methods: {
|
||||
async loadHistory() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const response = await apiClient.get('/mytischtennis/update-history');
|
||||
this.history = response.data.history || [];
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der History:', error);
|
||||
this.history = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
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',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</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: 800px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
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;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.no-history {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-entry {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.history-status {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-status.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.history-status.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.history-message {
|
||||
margin-top: 0.5rem;
|
||||
color: #495057;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.history-error {
|
||||
margin-top: 0.5rem;
|
||||
color: #dc3545;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.history-stats {
|
||||
margin-top: 0.5rem;
|
||||
color: #28a745;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
</style>
|
||||
@@ -192,23 +192,101 @@ class PDFGenerator {
|
||||
}
|
||||
|
||||
addPhoneListHeaders() {
|
||||
this.pdf.setFontSize(10);
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.text('Name, Vorname', this.margin, this.yPos);
|
||||
this.pdf.text('Geburtsdatum', this.margin + 60, this.yPos);
|
||||
this.pdf.text('Telefon-Nr.', this.margin + 120, this.yPos);
|
||||
this.yPos += this.LINE_HEIGHT;
|
||||
this.pdf.setFont('helvetica', 'normal');
|
||||
this.pdf.setFontSize(10); // Sicherstellen, dass die Schriftgröße für die Einträge korrekt ist
|
||||
}
|
||||
|
||||
addPhoneListRow(member) {
|
||||
const fullName = `${member.lastName}, ${member.firstName}`;
|
||||
const birthDate = member.birthDate ? new Date(member.birthDate).toLocaleDateString('de-DE') : '';
|
||||
const phoneNumber = member.phone || '';
|
||||
|
||||
this.pdf.text(fullName, this.margin, this.yPos);
|
||||
this.pdf.text(birthDate, this.margin + 60, this.yPos);
|
||||
this.pdf.text(phoneNumber, this.margin + 120, this.yPos);
|
||||
this.yPos += this.LINE_HEIGHT;
|
||||
|
||||
// Sammle alle Telefonnummern aus contacts
|
||||
let phoneContacts = [];
|
||||
if (member.contacts && Array.isArray(member.contacts)) {
|
||||
phoneContacts = member.contacts
|
||||
.filter(c => c.type === 'phone' && c.value && String(c.value).trim() !== '')
|
||||
.sort((a, b) => {
|
||||
// Primäre Telefonnummer zuerst
|
||||
if (a.isPrimary && !b.isPrimary) return -1;
|
||||
if (!a.isPrimary && b.isPrimary) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback auf altes phone-Feld für Rückwärtskompatibilität
|
||||
if (phoneContacts.length === 0 && member.phone) {
|
||||
phoneContacts.push({
|
||||
value: member.phone,
|
||||
isParent: false,
|
||||
parentName: null
|
||||
});
|
||||
}
|
||||
|
||||
// Startposition für Name und Geburtsdatum
|
||||
const startYPos = this.yPos;
|
||||
|
||||
// Sicherstellen, dass die Schriftgröße korrekt ist
|
||||
this.pdf.setFontSize(10);
|
||||
this.pdf.setFont('helvetica', 'normal');
|
||||
|
||||
// Zeige Name und Geburtsdatum nur einmal am Anfang
|
||||
this.pdf.text(fullName, this.margin, startYPos);
|
||||
this.pdf.text(birthDate, this.margin + 60, startYPos);
|
||||
|
||||
// Zeige alle Telefonnummern untereinander
|
||||
if (phoneContacts.length > 0) {
|
||||
let currentYPos = startYPos;
|
||||
|
||||
phoneContacts.forEach((contact, index) => {
|
||||
// Telefonnummer in normaler Schriftgröße
|
||||
this.pdf.setFont('helvetica', 'normal');
|
||||
this.pdf.setFontSize(10);
|
||||
this.pdf.text(contact.value, this.margin + 120, currentYPos);
|
||||
|
||||
// Y-Position nach der Telefonnummer erhöhen
|
||||
currentYPos += 4; // Höhe der Telefonnummer
|
||||
|
||||
// Bei Elternteil-Nummern: Name in kleiner Schrift darunter
|
||||
if (contact.isParent) {
|
||||
// Name in kleiner Schrift
|
||||
this.pdf.setFont('helvetica', 'normal');
|
||||
this.pdf.setFontSize(7); // Deutlich kleinere Schrift
|
||||
let parentText = '';
|
||||
if (contact.parentName) {
|
||||
parentText = contact.parentName; // Nur der Name des Elternteils
|
||||
} else {
|
||||
parentText = 'Elternteil';
|
||||
}
|
||||
this.pdf.text(parentText, this.margin + 120, currentYPos);
|
||||
|
||||
// Y-Position nach dem Namen erhöhen
|
||||
currentYPos += 3; // Höhe des Namens
|
||||
|
||||
// Zurück zur normalen Schriftgröße
|
||||
this.pdf.setFontSize(10);
|
||||
}
|
||||
|
||||
// Kleiner Abstand zur nächsten Nummer (nur wenn nicht die letzte)
|
||||
if (index < phoneContacts.length - 1) {
|
||||
currentYPos += 1; // Minimaler Abstand zwischen Nummern
|
||||
}
|
||||
});
|
||||
|
||||
// Aktualisiere yPos
|
||||
this.yPos = currentYPos;
|
||||
} else {
|
||||
// Keine Telefonnummern vorhanden
|
||||
this.yPos += this.LINE_HEIGHT;
|
||||
}
|
||||
|
||||
// Zusätzlicher Abstand zwischen Mitgliedern
|
||||
this.yPos += 2;
|
||||
}
|
||||
|
||||
addAddress(clubName, addressLines) {
|
||||
@@ -241,27 +319,71 @@ class PDFGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
addTable(tableId, highlightName = '') {
|
||||
addTable(tableId, highlightName = '', excludeColumns = []) {
|
||||
this.pdf.setFontSize(11);
|
||||
autoTable(this.pdf, {
|
||||
html: `#${tableId}`,
|
||||
startY: this.cursorY,
|
||||
margin: { left: this.margin, right: this.margin },
|
||||
styles: { fontSize: this.pdf.getFontSize() },
|
||||
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left' },
|
||||
theme: 'grid',
|
||||
didParseCell: (data) => {
|
||||
const cellText = Array.isArray(data.cell.text)
|
||||
? data.cell.text.join(' ')
|
||||
: String(data.cell.text);
|
||||
if (highlightName && cellText.includes(highlightName)) {
|
||||
data.cell.styles.fontStyle = 'bold';
|
||||
|
||||
// Wenn Spalten ausgeschlossen werden sollen, parse die Tabelle manuell
|
||||
if (excludeColumns.length > 0) {
|
||||
const table = document.querySelector(`#${tableId}`);
|
||||
if (!table) return;
|
||||
|
||||
const headers = Array.from(table.querySelectorAll('thead th'))
|
||||
.map((th, index) => ({ text: th.textContent.trim(), index }))
|
||||
.filter(h => !excludeColumns.includes(h.index))
|
||||
.map(h => h.text);
|
||||
|
||||
const rows = Array.from(table.querySelectorAll('tbody tr')).map(tr => {
|
||||
return Array.from(tr.querySelectorAll('td'))
|
||||
.map((td, index) => ({ text: td.textContent.trim(), index }))
|
||||
.filter(cell => !excludeColumns.includes(cell.index))
|
||||
.map(cell => cell.text);
|
||||
});
|
||||
|
||||
autoTable(this.pdf, {
|
||||
head: [headers],
|
||||
body: rows,
|
||||
startY: this.cursorY,
|
||||
margin: { left: this.margin, right: this.margin },
|
||||
styles: { fontSize: this.pdf.getFontSize(), cellPadding: 3, overflow: 'linebreak' },
|
||||
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left', fontStyle: 'bold' },
|
||||
theme: 'grid',
|
||||
tableWidth: 'auto',
|
||||
didParseCell: (data) => {
|
||||
if (data.section === 'body') {
|
||||
const cellText = Array.isArray(data.cell.text)
|
||||
? data.cell.text.join(' ')
|
||||
: String(data.cell.text);
|
||||
if (highlightName && cellText.includes(highlightName)) {
|
||||
data.cell.styles.fontStyle = 'bold';
|
||||
}
|
||||
}
|
||||
},
|
||||
didDrawPage: (data) => {
|
||||
this.cursorY = data.cursor.y + 10;
|
||||
}
|
||||
},
|
||||
didDrawPage: (data) => {
|
||||
this.cursorY = data.cursor.y + 10;
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Ohne Ausschluss: normale HTML-Tabelle verwenden
|
||||
autoTable(this.pdf, {
|
||||
html: `#${tableId}`,
|
||||
startY: this.cursorY,
|
||||
margin: { left: this.margin, right: this.margin },
|
||||
styles: { fontSize: this.pdf.getFontSize() },
|
||||
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left' },
|
||||
theme: 'grid',
|
||||
didParseCell: (data) => {
|
||||
const cellText = Array.isArray(data.cell.text)
|
||||
? data.cell.text.join(' ')
|
||||
: String(data.cell.text);
|
||||
if (highlightName && cellText.includes(highlightName)) {
|
||||
data.cell.styles.fontStyle = 'bold';
|
||||
}
|
||||
},
|
||||
didDrawPage: (data) => {
|
||||
this.cursorY = data.cursor.y + 10;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addParticipantsSummary(tournamentTitle, tournamentDateText, groups) {
|
||||
|
||||
210
frontend/src/components/QuickAddMemberDialog.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
title="Neues Mitglied hinzufügen"
|
||||
size="medium"
|
||||
:close-on-overlay="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="quick-add-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="firstName">Vorname:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
:value="localMember.firstName"
|
||||
@input="updateMember('firstName', $event.target.value)"
|
||||
required
|
||||
class="form-input"
|
||||
placeholder="Vorname"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lastName">Nachname (optional):</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
:value="localMember.lastName"
|
||||
@input="updateMember('lastName', $event.target.value)"
|
||||
class="form-input"
|
||||
placeholder="Nachname"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="birthDate">Geburtsdatum (optional):</label>
|
||||
<input
|
||||
type="date"
|
||||
id="birthDate"
|
||||
:value="localMember.birthDate"
|
||||
@input="updateMember('birthDate', $event.target.value)"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="gender">Geschlecht:</label>
|
||||
<select id="gender" :value="localMember.gender" @change="updateMember('gender', $event.target.value)" class="form-select">
|
||||
<option value="">Bitte wählen</option>
|
||||
<option value="male">Männlich</option>
|
||||
<option value="female">Weiblich</option>
|
||||
<option value="diverse">Divers</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<button class="btn-secondary" @click="handleClose">Abbrechen</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
@click="handleSubmit"
|
||||
:disabled="!isValid"
|
||||
>
|
||||
Erstellen & Hinzufügen
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'QuickAddMemberDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
default: () => ({ firstName: '', lastName: '', birthDate: '', gender: '' })
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'submit', 'update:member'],
|
||||
data() {
|
||||
return {
|
||||
localMember: { ...this.member }
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isValid() {
|
||||
return this.localMember.firstName && this.localMember.firstName.trim() !== '';
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
member: {
|
||||
handler(newVal) {
|
||||
this.localMember = { ...newVal };
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateMember(field, value) {
|
||||
this.localMember[field] = value;
|
||||
this.$emit('update:member', { ...this.localMember });
|
||||
},
|
||||
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
handleSubmit() {
|
||||
if (this.isValid) {
|
||||
this.$emit('submit', this.localMember);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.quick-add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-light);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,6 +40,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -47,8 +68,14 @@ import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import apiClient from '../apiClient.js';
|
||||
|
||||
import InfoDialog from './InfoDialog.vue';
|
||||
import ConfirmDialog from './ConfirmDialog.vue';
|
||||
export default {
|
||||
name: 'SeasonSelector',
|
||||
components: {
|
||||
InfoDialog,
|
||||
ConfirmDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
@@ -63,6 +90,23 @@ export default {
|
||||
setup(props, { emit }) {
|
||||
const store = useStore();
|
||||
|
||||
// Dialog States
|
||||
const infoDialog = ref({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
});
|
||||
const confirmDialog = ref({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const seasons = ref([]);
|
||||
const selectedSeasonId = ref(props.modelValue);
|
||||
@@ -124,12 +168,12 @@ export default {
|
||||
// Formular zurücksetzen
|
||||
newSeasonString.value = '';
|
||||
showNewSeasonForm.value = false;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Saison:', error);
|
||||
if (error.response?.data?.error === 'alreadyexists') {
|
||||
alert('Diese Saison existiert bereits!');
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Erstellen der Saison:', err);
|
||||
if (err.response?.data?.error === 'alreadyexists') {
|
||||
showInfo('Hinweis', 'Diese Saison existiert bereits!', '', 'warning');
|
||||
} else {
|
||||
alert('Fehler beim Erstellen der Saison');
|
||||
showInfo('Fehler', 'Fehler beim Erstellen der Saison', '', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -139,6 +183,38 @@ export default {
|
||||
showNewSeasonForm.value = false;
|
||||
};
|
||||
|
||||
// Dialog Helper Methods
|
||||
const showInfo = async (title, message, details = '', type = 'info') => {
|
||||
infoDialog.value = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type
|
||||
};
|
||||
};
|
||||
|
||||
const showConfirm = async (title, message, details = '', type = 'info') => {
|
||||
return new Promise((resolve) => {
|
||||
confirmDialog.value = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmResult = (confirmed) => {
|
||||
if (confirmDialog.value.resolveCallback) {
|
||||
confirmDialog.value.resolveCallback(confirmed);
|
||||
confirmDialog.value.resolveCallback = null;
|
||||
}
|
||||
confirmDialog.value.isOpen = false;
|
||||
};
|
||||
|
||||
// Watch for prop changes
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
selectedSeasonId.value = newValue;
|
||||
@@ -150,6 +226,11 @@ export default {
|
||||
});
|
||||
|
||||
return {
|
||||
infoDialog,
|
||||
confirmDialog,
|
||||
showInfo,
|
||||
showConfirm,
|
||||
handleConfirmResult,
|
||||
seasons,
|
||||
selectedSeasonId,
|
||||
showNewSeasonForm,
|
||||
|
||||
154
frontend/src/components/TagHistoryDialog.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="`Tag-Historie ${member ? member.firstName + ' ' + member.lastName : ''}`"
|
||||
size="medium"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="tag-history-content">
|
||||
<div class="form-group">
|
||||
<label>Tags auswählen</label>
|
||||
<multiselect
|
||||
v-model="localSelectedTags"
|
||||
:options="activityTags"
|
||||
placeholder="Tags auswählen"
|
||||
label="name"
|
||||
track-by="id"
|
||||
multiple
|
||||
:close-on-select="false"
|
||||
:taggable="false"
|
||||
@select="$emit('select-tag', $event)"
|
||||
:allow-empty="false"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="tagHistory && tagHistory.length" class="tag-history-list">
|
||||
<div v-for="tag in tagHistory" :key="tag.id" class="tag-history-item">
|
||||
<div class="tag-header">{{ tag.name }}</div>
|
||||
<ul class="tag-list">
|
||||
<li v-for="entry in tag.diaryMemberTags" :key="entry.id">
|
||||
{{ formatDate(entry.diaryDates.date) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-history">
|
||||
<em>Keine Tag-Historie vorhanden</em>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
|
||||
export default {
|
||||
name: 'TagHistoryDialog',
|
||||
components: {
|
||||
BaseDialog,
|
||||
Multiselect
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
tagHistory: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedTags: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activityTags: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'select-tag', 'update:selectedTags'],
|
||||
data() {
|
||||
return {
|
||||
localSelectedTags: this.selectedTags
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
selectedTags(newVal) {
|
||||
this.localSelectedTags = newVal;
|
||||
},
|
||||
localSelectedTags(newVal) {
|
||||
this.$emit('update:selectedTags', newVal);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tag-history-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tag-history-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tag-history-item {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tag-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
list-style: none;
|
||||
padding-left: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tag-list li {
|
||||
padding: 0.25rem 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-history {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
222
frontend/src/components/TrainingDetailsDialog.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="`Trainings-Details: ${member ? member.firstName + ' ' + member.lastName : ''}`"
|
||||
size="large"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="member" class="details-modal-content">
|
||||
<div class="member-info">
|
||||
<div class="info-item">
|
||||
<strong>Geburtsdatum:</strong> {{ formatBirthdate(member.birthDate) }}
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Geburtsjahr:</strong> {{ getBirthYear(member.birthDate) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="participation-summary">
|
||||
<div class="summary-item">
|
||||
<span class="label">Letzte 12 Monate:</span>
|
||||
<span class="value">{{ member.participation12Months }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Letzte 3 Monate:</span>
|
||||
<span class="value">{{ member.participation3Months }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Gesamt:</span>
|
||||
<span class="value">{{ member.participationTotal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="training-details">
|
||||
<h4>Trainingsteilnahmen (absteigend sortiert)</h4>
|
||||
<div class="training-list">
|
||||
<div
|
||||
v-for="training in member.trainingDetails"
|
||||
:key="training.id"
|
||||
class="training-item"
|
||||
>
|
||||
<div class="training-date">{{ formatDate(training.date) }}</div>
|
||||
<div class="training-activity">{{ training.activityName }}</div>
|
||||
<div class="training-time">{{ training.startTime }} - {{ training.endTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!member.trainingDetails || member.trainingDetails.length === 0" class="no-trainings">
|
||||
<em>Keine Trainingsteilnahmen vorhanden</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'TrainingDetailsDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close'],
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
formatBirthdate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE');
|
||||
},
|
||||
getBirthYear(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.getFullYear();
|
||||
},
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.details-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
padding: 1rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-item strong {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.participation-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
padding: 1rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-item .label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-item .value {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.training-details h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.training-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.training-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.training-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.training-item:hover {
|
||||
background: var(--background-light);
|
||||
}
|
||||
|
||||
.training-date {
|
||||
font-weight: 600;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.training-activity {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.training-time {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.no-trainings {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.member-info {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.participation-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.training-item {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.training-date {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
155
frontend/src/composables/useDialog.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ref } from 'vue';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage } from '../utils/dialogUtils.js';
|
||||
|
||||
/**
|
||||
* Composable für einfache Dialog-Verwaltung
|
||||
*
|
||||
* Verwendung:
|
||||
*
|
||||
* // In der Component:
|
||||
* const { isOpen, open, close } = useDialog();
|
||||
*
|
||||
* // Dialog öffnen:
|
||||
* open();
|
||||
*
|
||||
* // Dialog schließen:
|
||||
* close();
|
||||
*
|
||||
* // Im Template:
|
||||
* <BaseDialog v-model="isOpen" ...>
|
||||
*/
|
||||
export function useDialog(initialState = false) {
|
||||
const isOpen = ref(initialState);
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable für Confirm-Dialog
|
||||
*
|
||||
* Verwendung:
|
||||
*
|
||||
* const { confirm } = useConfirm();
|
||||
*
|
||||
* const deleted = await confirm({
|
||||
* title: 'Löschen bestätigen',
|
||||
* message: 'Möchten Sie diesen Eintrag wirklich löschen?',
|
||||
* type: 'danger'
|
||||
* });
|
||||
*
|
||||
* if (deleted) {
|
||||
* // Löschvorgang durchführen
|
||||
* }
|
||||
*/
|
||||
export function useConfirm() {
|
||||
const isOpen = ref(false);
|
||||
const config = ref({});
|
||||
let resolvePromise = null;
|
||||
|
||||
const confirm = (options = {}) => {
|
||||
config.value = buildConfirmConfig(options);
|
||||
|
||||
isOpen.value = true;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
isOpen.value = false;
|
||||
if (resolvePromise) {
|
||||
resolvePromise(true);
|
||||
resolvePromise = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
isOpen.value = false;
|
||||
if (resolvePromise) {
|
||||
resolvePromise(false);
|
||||
resolvePromise = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
config,
|
||||
confirm,
|
||||
handleConfirm,
|
||||
handleCancel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable für Info-Dialog
|
||||
*
|
||||
* Verwendung:
|
||||
*
|
||||
* const { showInfo } = useInfo();
|
||||
*
|
||||
* await showInfo({
|
||||
* title: 'Erfolg',
|
||||
* message: 'Der Vorgang wurde erfolgreich abgeschlossen.',
|
||||
* type: 'success'
|
||||
* });
|
||||
*/
|
||||
export function useInfo() {
|
||||
const isOpen = ref(false);
|
||||
const config = ref({});
|
||||
let resolvePromise = null;
|
||||
|
||||
const showInfo = (options = {}) => {
|
||||
config.value = buildInfoConfig(options);
|
||||
|
||||
isOpen.value = true;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
isOpen.value = false;
|
||||
if (resolvePromise) {
|
||||
resolvePromise(true);
|
||||
resolvePromise = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
isOpen.value = false;
|
||||
if (resolvePromise) {
|
||||
resolvePromise(false);
|
||||
resolvePromise = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
config,
|
||||
showInfo,
|
||||
handleOk,
|
||||
handleClose
|
||||
};
|
||||
}
|
||||
|
||||
export { safeErrorMessage };
|
||||
|
||||
66
frontend/src/composables/usePermissions.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
/**
|
||||
* Composable for permission checks in Vue components
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const store = useStore();
|
||||
|
||||
const permissions = computed(() => store.getters.currentPermissions);
|
||||
const isOwner = computed(() => store.getters.isClubOwner);
|
||||
const userRole = computed(() => store.getters.userRole);
|
||||
|
||||
/**
|
||||
* Check if user has specific permission
|
||||
* @param {string} resource - Resource name (diary, members, teams, etc.)
|
||||
* @param {string} action - Action type (read, write, delete)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const can = (resource, action = 'read') => {
|
||||
return store.getters.hasPermission(resource, action);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user can read
|
||||
*/
|
||||
const canRead = (resource) => can(resource, 'read');
|
||||
|
||||
/**
|
||||
* Check if user can write
|
||||
*/
|
||||
const canWrite = (resource) => can(resource, 'write');
|
||||
|
||||
/**
|
||||
* Check if user can delete
|
||||
*/
|
||||
const canDelete = (resource) => can(resource, 'delete');
|
||||
|
||||
/**
|
||||
* Check if user is admin (owner or admin role)
|
||||
*/
|
||||
const isAdmin = computed(() => {
|
||||
return isOwner.value || userRole.value === 'admin';
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if user has specific role
|
||||
*/
|
||||
const hasRole = (role) => {
|
||||
return userRole.value === role;
|
||||
};
|
||||
|
||||
return {
|
||||
permissions,
|
||||
isOwner,
|
||||
isAdmin,
|
||||
userRole,
|
||||
can,
|
||||
canRead,
|
||||
canWrite,
|
||||
canDelete,
|
||||
hasRole
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
197
frontend/src/directives/permissions.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Vue directive for permission-based element visibility
|
||||
* Usage: v-can:resource.action or v-can="'resource.action'"
|
||||
*
|
||||
* Examples:
|
||||
* <button v-can:diary.write>Bearbeiten</button>
|
||||
* <button v-can:diary.delete>Löschen</button>
|
||||
* <div v-can="'members.write'">...</div>
|
||||
*/
|
||||
|
||||
const checkPermission = (el, binding, vnode) => {
|
||||
// Safely access store
|
||||
if (!vnode.appContext || !vnode.appContext.config || !vnode.appContext.config.globalProperties) {
|
||||
// Hide by default if store not available (deny by default)
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const store = vnode.appContext.config.globalProperties.$store;
|
||||
|
||||
if (!store) {
|
||||
// Hide by default if store not found (deny by default)
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
let resource, action;
|
||||
|
||||
// Parse directive value
|
||||
if (typeof binding.value === 'string') {
|
||||
// v-can="'diary.write'"
|
||||
[resource, action] = binding.value.split('.');
|
||||
} else if (binding.arg) {
|
||||
// v-can:diary.write
|
||||
resource = binding.arg;
|
||||
action = Object.keys(binding.modifiers)[0] || 'read';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPermission = store.getters.hasPermission(resource, action);
|
||||
|
||||
if (hasPermission) {
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
export const canDirective = {
|
||||
mounted(el, binding, vnode) {
|
||||
// Initial check
|
||||
checkPermission(el, binding, vnode);
|
||||
|
||||
// Set up watcher for permissions changes
|
||||
if (vnode.appContext && vnode.appContext.config && vnode.appContext.config.globalProperties) {
|
||||
const store = vnode.appContext.config.globalProperties.$store;
|
||||
if (store) {
|
||||
// Watch both permissions and currentClub
|
||||
el._permissionUnwatch = store.subscribe((mutation) => {
|
||||
if (mutation.type === 'setPermissions' || mutation.type === 'setClub') {
|
||||
checkPermission(el, binding, vnode);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updated(el, binding, vnode) {
|
||||
checkPermission(el, binding, vnode);
|
||||
},
|
||||
|
||||
unmounted(el) {
|
||||
// Clean up watcher
|
||||
if (el._permissionUnwatch) {
|
||||
el._permissionUnwatch();
|
||||
delete el._permissionUnwatch;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Directive for admin-only elements
|
||||
* Usage: v-admin
|
||||
*/
|
||||
const checkAdmin = (el, vnode) => {
|
||||
if (!vnode.appContext || !vnode.appContext.config || !vnode.appContext.config.globalProperties) {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const store = vnode.appContext.config.globalProperties.$store;
|
||||
|
||||
if (!store) {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const isOwner = store.getters.isClubOwner;
|
||||
const role = store.getters.userRole;
|
||||
|
||||
if (isOwner || role === 'admin') {
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
export const adminDirective = {
|
||||
mounted(el, binding, vnode) {
|
||||
checkAdmin(el, vnode);
|
||||
|
||||
if (vnode.appContext && vnode.appContext.config && vnode.appContext.config.globalProperties) {
|
||||
const store = vnode.appContext.config.globalProperties.$store;
|
||||
if (store) {
|
||||
el._adminUnwatch = store.subscribe((mutation) => {
|
||||
if (mutation.type === 'setPermissions' || mutation.type === 'setClub') {
|
||||
checkAdmin(el, vnode);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updated(el, binding, vnode) {
|
||||
checkAdmin(el, vnode);
|
||||
},
|
||||
|
||||
unmounted(el) {
|
||||
if (el._adminUnwatch) {
|
||||
el._adminUnwatch();
|
||||
delete el._adminUnwatch;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Directive for owner-only elements
|
||||
* Usage: v-owner
|
||||
*/
|
||||
const checkOwner = (el, vnode) => {
|
||||
if (!vnode.appContext || !vnode.appContext.config || !vnode.appContext.config.globalProperties) {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const store = vnode.appContext.config.globalProperties.$store;
|
||||
|
||||
if (!store) {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const isOwner = store.getters.isClubOwner;
|
||||
|
||||
if (isOwner) {
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
export const ownerDirective = {
|
||||
mounted(el, binding, vnode) {
|
||||
checkOwner(el, vnode);
|
||||
|
||||
if (vnode.appContext && vnode.appContext.config && vnode.appContext.config.globalProperties) {
|
||||
const store = vnode.appContext.config.globalProperties.$store;
|
||||
if (store) {
|
||||
el._ownerUnwatch = store.subscribe((mutation) => {
|
||||
if (mutation.type === 'setPermissions' || mutation.type === 'setClub') {
|
||||
checkOwner(el, vnode);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updated(el, binding, vnode) {
|
||||
checkOwner(el, vnode);
|
||||
},
|
||||
|
||||
unmounted(el) {
|
||||
if (el._ownerUnwatch) {
|
||||
el._ownerUnwatch();
|
||||
delete el._ownerUnwatch;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
can: canDirective,
|
||||
admin: adminDirective,
|
||||
owner: ownerDirective
|
||||
};
|
||||
|
||||
@@ -4,9 +4,16 @@ import router from './router';
|
||||
import store from './store';
|
||||
import '@/assets/css/main.scss';
|
||||
import './assets/css/vue-multiselect.css';
|
||||
import permissionDirectives from './directives/permissions.js';
|
||||
|
||||
const app = createApp(App);
|
||||
app.config.devtools = true;
|
||||
|
||||
// Register permission directives
|
||||
app.directive('can', permissionDirectives.can);
|
||||
app.directive('admin', permissionDirectives.admin);
|
||||
app.directive('owner', permissionDirectives.owner);
|
||||
|
||||
app
|
||||
.use(router)
|
||||
.use(store)
|
||||
|
||||
@@ -16,6 +16,9 @@ import PredefinedActivities from './views/PredefinedActivities.vue';
|
||||
import OfficialTournaments from './views/OfficialTournaments.vue';
|
||||
import MyTischtennisAccount from './views/MyTischtennisAccount.vue';
|
||||
import TeamManagementView from './views/TeamManagementView.vue';
|
||||
import PermissionsView from './views/PermissionsView.vue';
|
||||
import LogsView from './views/LogsView.vue';
|
||||
import MemberTransferSettingsView from './views/MemberTransferSettingsView.vue';
|
||||
import Impressum from './views/Impressum.vue';
|
||||
import Datenschutz from './views/Datenschutz.vue';
|
||||
|
||||
@@ -37,6 +40,9 @@ const routes = [
|
||||
{ path: '/official-tournaments', component: OfficialTournaments },
|
||||
{ path: '/mytischtennis-account', component: MyTischtennisAccount },
|
||||
{ path: '/team-management', component: TeamManagementView },
|
||||
{ path: '/permissions', component: PermissionsView },
|
||||
{ path: '/logs', component: LogsView },
|
||||
{ path: '/member-transfer-settings', component: MemberTransferSettingsView },
|
||||
{ path: '/impressum', component: Impressum },
|
||||
{ path: '/datenschutz', component: Datenschutz },
|
||||
];
|
||||
|
||||
@@ -1,49 +1,94 @@
|
||||
import { createStore } from 'vuex';
|
||||
import router from './router.js';
|
||||
import apiClient from './apiClient.js';
|
||||
import { safeSessionStorage, safeLocalStorage } from './utils/storage.js';
|
||||
|
||||
const store = createStore({
|
||||
state: {
|
||||
token: localStorage.getItem('token') || null,
|
||||
username: localStorage.getItem('username') || '',
|
||||
currentClub: localStorage.getItem('currentClub') || null,
|
||||
token: safeSessionStorage.getItem('token'),
|
||||
username: safeSessionStorage.getItem('username') || '',
|
||||
currentClub: safeSessionStorage.getItem('currentClub'),
|
||||
clubs: (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('clubs')) || [];
|
||||
const stored = safeLocalStorage.getItem('clubs');
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (e) {
|
||||
this.clubs = [];
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
permissions: (() => {
|
||||
try {
|
||||
const stored = safeLocalStorage.getItem('clubPermissions');
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
})(), // { clubId: { role, isOwner, permissions: {...} } }
|
||||
dialogs: [], // Array von offenen Dialogen
|
||||
dialogCounter: 0, // Zähler für eindeutige Dialog-IDs
|
||||
sidebarCollapsed: (() => {
|
||||
const savedState = safeLocalStorage.getItem('sidebarCollapsed');
|
||||
if (savedState !== null) {
|
||||
return savedState === 'true';
|
||||
}
|
||||
// Standardmäßig kollabiert auf mobilen Geräten
|
||||
return window.innerWidth <= 480;
|
||||
})(),
|
||||
},
|
||||
mutations: {
|
||||
setToken(state, token) {
|
||||
state.token = token;
|
||||
localStorage.setItem('token', token);
|
||||
if (token) {
|
||||
safeSessionStorage.setItem('token', token);
|
||||
} else {
|
||||
safeSessionStorage.removeItem('token');
|
||||
}
|
||||
state.currentClub = null;
|
||||
localStorage.setItem('currentClub', null);
|
||||
safeSessionStorage.removeItem('currentClub');
|
||||
},
|
||||
setUsername(state, username) {
|
||||
state.username = username;
|
||||
localStorage.setItem('username', username);
|
||||
if (username) {
|
||||
safeSessionStorage.setItem('username', username);
|
||||
} else {
|
||||
safeSessionStorage.removeItem('username');
|
||||
}
|
||||
},
|
||||
setClub(state, club) {
|
||||
state.currentClub = club;
|
||||
localStorage.setItem('currentClub', club);
|
||||
if (club) {
|
||||
safeSessionStorage.setItem('currentClub', club);
|
||||
} else {
|
||||
safeSessionStorage.removeItem('currentClub');
|
||||
}
|
||||
},
|
||||
setClubsMutation(state, clubs) {
|
||||
state.clubs = clubs;
|
||||
localStorage.setItem('clubs', JSON.stringify(clubs));
|
||||
safeLocalStorage.setItem('clubs', JSON.stringify(clubs));
|
||||
},
|
||||
setPermissions(state, { clubId, permissions }) {
|
||||
state.permissions = {
|
||||
...state.permissions,
|
||||
[clubId]: permissions
|
||||
};
|
||||
safeLocalStorage.setItem('clubPermissions', JSON.stringify(state.permissions));
|
||||
},
|
||||
clearPermissions(state) {
|
||||
state.permissions = {};
|
||||
safeLocalStorage.removeItem('clubPermissions');
|
||||
},
|
||||
setSidebarCollapsed(state, collapsed) {
|
||||
state.sidebarCollapsed = collapsed;
|
||||
safeLocalStorage.setItem('sidebarCollapsed', collapsed.toString());
|
||||
},
|
||||
clearToken(state) {
|
||||
state.token = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('currentClub');
|
||||
safeSessionStorage.removeItem('token');
|
||||
safeSessionStorage.removeItem('currentClub');
|
||||
},
|
||||
clearUsername(state) {
|
||||
state.username = '';
|
||||
localStorage.removeItem('username');
|
||||
safeSessionStorage.removeItem('username');
|
||||
},
|
||||
// Dialog-Mutations
|
||||
openDialog(state, dialog) {
|
||||
@@ -93,16 +138,40 @@ const store = createStore({
|
||||
logout({ commit }) {
|
||||
commit('clearToken');
|
||||
commit('clearUsername');
|
||||
commit('clearPermissions');
|
||||
router.push('/login'); // Leitet den Benutzer zur Login-Seite um
|
||||
// window.location.reload() entfernt, um Endlos-Neuladeschleife zu verhindern
|
||||
},
|
||||
|
||||
setCurrentClub({ commit }, club) {
|
||||
async setCurrentClub({ commit, dispatch }, club) {
|
||||
commit('setClub', club);
|
||||
// Load permissions for this club
|
||||
await dispatch('loadPermissions', club);
|
||||
},
|
||||
|
||||
async loadPermissions({ commit }, clubId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/permissions/${clubId}`);
|
||||
commit('setPermissions', { clubId, permissions: response.data });
|
||||
} catch (error) {
|
||||
console.error('Error loading permissions:', error);
|
||||
// Set default permissions (read-only)
|
||||
commit('setPermissions', {
|
||||
clubId,
|
||||
permissions: {
|
||||
role: 'member',
|
||||
isOwner: false,
|
||||
permissions: {}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
setClubs({ commit }, clubs) {
|
||||
commit('setClubsMutation', clubs);
|
||||
},
|
||||
toggleSidebar({ commit, state }) {
|
||||
commit('setSidebarCollapsed', !state.sidebarCollapsed);
|
||||
},
|
||||
// Dialog-Actions
|
||||
openDialog({ commit }, dialog) {
|
||||
commit('openDialog', dialog);
|
||||
@@ -126,10 +195,36 @@ const store = createStore({
|
||||
username: state => state.username,
|
||||
currentClub: state => state.currentClub,
|
||||
clubs: state => state.clubs,
|
||||
sidebarCollapsed: state => state.sidebarCollapsed,
|
||||
currentClubName: state => {
|
||||
const club = state.clubs.find(club => club.id === parseInt(state.currentClub));
|
||||
return club ? club.name : '';
|
||||
},
|
||||
// Permission getters
|
||||
currentPermissions: state => {
|
||||
if (!state.currentClub) return null;
|
||||
return state.permissions[state.currentClub] || null;
|
||||
},
|
||||
hasPermission: (state) => (resource, action) => {
|
||||
if (!state.currentClub) return false;
|
||||
const perms = state.permissions[state.currentClub];
|
||||
if (!perms) return false;
|
||||
if (perms.isOwner) return true;
|
||||
if (resource === 'mytischtennis') return true; // MyTischtennis für alle
|
||||
const resourcePerms = perms.permissions[resource];
|
||||
if (!resourcePerms) return false;
|
||||
return resourcePerms[action] === true;
|
||||
},
|
||||
isClubOwner: state => {
|
||||
if (!state.currentClub) return false;
|
||||
const perms = state.permissions[state.currentClub];
|
||||
return perms?.isOwner || false;
|
||||
},
|
||||
userRole: state => {
|
||||
if (!state.currentClub) return null;
|
||||
const perms = state.permissions[state.currentClub];
|
||||
return perms?.role || null; // null wenn nicht geladen, nicht 'member'
|
||||
},
|
||||
// Dialog-Getters
|
||||
dialogs: state => state.dialogs,
|
||||
minimizedDialogs: state => state.dialogs.filter(dialog => dialog.isMinimized),
|
||||
|
||||
10
frontend/src/utils/debounce.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export function debounce(fn, wait = 300) {
|
||||
let timeoutId;
|
||||
return function (...args) {
|
||||
const context = this;
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
fn.apply(context, args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
103
frontend/src/utils/dialogUtils.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const INFO_TYPES = new Set(['info', 'success', 'warning', 'error']);
|
||||
const CONFIRM_TYPES = new Set(['info', 'warning', 'danger', 'success']);
|
||||
|
||||
function ensureString(value, fallback = '') {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeText(value, fallback = '', maxLength = 500) {
|
||||
let text = ensureString(value, fallback).replace(/[\u0000-\u001F]+/g, ' ').trim();
|
||||
if (!text) {
|
||||
text = fallback;
|
||||
}
|
||||
if (text.length > maxLength) {
|
||||
return `${text.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function buildInfoConfig({
|
||||
title = 'Information',
|
||||
message = '',
|
||||
details = '',
|
||||
type = 'info',
|
||||
size = 'small',
|
||||
closeOnOverlay = true,
|
||||
okText = 'OK',
|
||||
icon = true,
|
||||
} = {}) {
|
||||
const safeType = INFO_TYPES.has(type) ? type : 'info';
|
||||
return {
|
||||
isOpen: true,
|
||||
title: sanitizeText(title, 'Information', 120),
|
||||
message: sanitizeText(message, '', 600),
|
||||
details: sanitizeText(details, '', 1200),
|
||||
type: safeType,
|
||||
size,
|
||||
closeOnOverlay,
|
||||
okText: sanitizeText(okText, 'OK', 40),
|
||||
icon: typeof icon === 'string' ? sanitizeText(icon, '', 10) : Boolean(icon),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConfirmConfig({
|
||||
title = 'Bestätigung',
|
||||
message = '',
|
||||
details = '',
|
||||
type = 'info',
|
||||
confirmText = 'OK',
|
||||
cancelText = 'Abbrechen',
|
||||
showCancel = true,
|
||||
resolveCallback = null,
|
||||
} = {}) {
|
||||
const safeType = CONFIRM_TYPES.has(type) ? type : 'info';
|
||||
return {
|
||||
isOpen: true,
|
||||
title: sanitizeText(title, 'Bestätigung', 120),
|
||||
message: sanitizeText(message, '', 600),
|
||||
details: sanitizeText(details, '', 1200),
|
||||
type: safeType,
|
||||
confirmText: sanitizeText(confirmText, 'OK', 40),
|
||||
cancelText: sanitizeText(cancelText, 'Abbrechen', 40),
|
||||
showCancel: Boolean(showCancel),
|
||||
resolveCallback,
|
||||
};
|
||||
}
|
||||
|
||||
export function safeErrorMessage(error, fallback = 'Es ist ein Fehler aufgetreten.') {
|
||||
if (!error) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
error?.response?.data?.error,
|
||||
error?.response?.data?.message,
|
||||
error?.response?.data?.errors && Array.isArray(error.response.data.errors)
|
||||
? error.response.data.errors.join(', ')
|
||||
: null,
|
||||
error?.message,
|
||||
error?.statusText,
|
||||
];
|
||||
|
||||
const message = candidates.find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
|
||||
return sanitizeText(message, fallback, 600);
|
||||
}
|
||||
|
||||
export function sanitizeDetails(details) {
|
||||
return sanitizeText(details, '', 1200);
|
||||
}
|
||||
|
||||
export { sanitizeText };
|
||||
50
frontend/src/utils/errorMessages.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const MAX_MESSAGE_LENGTH = 300;
|
||||
|
||||
function normalizeWhitespace(value) {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function sanitizeText(value, fallback = '') {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const cleaned = normalizeWhitespace(
|
||||
value
|
||||
.replace(/[\u0000-\u001F\u007F]/g, ' ')
|
||||
.replace(/[<>]/g, '')
|
||||
);
|
||||
|
||||
if (!cleaned) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (cleaned.length > MAX_MESSAGE_LENGTH) {
|
||||
return `${cleaned.slice(0, MAX_MESSAGE_LENGTH)}…`;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function getSafeErrorMessage(error, fallback = 'Unbekannter Fehler') {
|
||||
const candidates = [
|
||||
error?.response?.data?.error,
|
||||
error?.response?.data?.message,
|
||||
error?.message
|
||||
];
|
||||
|
||||
const firstValid = candidates.find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
|
||||
if (!firstValid) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return sanitizeText(firstValid, fallback);
|
||||
}
|
||||
|
||||
export function getSafeMessage(value, fallback = '') {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return sanitizeText(String(value), fallback);
|
||||
}
|
||||
35
frontend/src/utils/storage.js
Normal file
@@ -0,0 +1,35 @@
|
||||
function createStorageWrapper(storage) {
|
||||
return {
|
||||
getItem(key) {
|
||||
try {
|
||||
return storage?.getItem(key) ?? null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem(key, value) {
|
||||
try {
|
||||
storage?.setItem(key, value);
|
||||
} catch (error) {
|
||||
/* noop */
|
||||
}
|
||||
},
|
||||
removeItem(key) {
|
||||
try {
|
||||
storage?.removeItem(key);
|
||||
} catch (error) {
|
||||
/* noop */
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
try {
|
||||
storage?.clear();
|
||||
} catch (error) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const safeSessionStorage = createStorageWrapper(typeof window !== 'undefined' ? window.sessionStorage : undefined);
|
||||
export const safeLocalStorage = createStorageWrapper(typeof window !== 'undefined' ? window.localStorage : undefined);
|
||||
@@ -3,21 +3,72 @@
|
||||
<h2>Activate Account</h2>
|
||||
<button @click="activate">Activate</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage } from '../utils/dialogUtils.js';
|
||||
export default {
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = buildInfoConfig({ title, message, details, type });
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info', options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = buildConfirmConfig({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
async activate() {
|
||||
try {
|
||||
const activationCode = this.$route.params.activationCode;
|
||||
await axios.get(`/api/auth/activate/${activationCode}`);
|
||||
alert('Account activated! You can now log in.');
|
||||
await this.showInfo('Erfolg', 'Account aktiviert! Du kannst dich jetzt anmelden.', '', 'success');
|
||||
this.$router.push('/login');
|
||||
} catch (error) {
|
||||
alert('Aktivierung fehlgeschlagen');
|
||||
const message = safeErrorMessage(error, 'Aktivierung fehlgeschlagen. Bitte überprüfe den Link oder versuche es erneut.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -26,12 +26,36 @@
|
||||
<button @click="requestAccess">Zugriff beantragen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import apiClient from '../apiClient';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage } from '../utils/dialogUtils.js';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
export default {
|
||||
name: "ClubView",
|
||||
computed: {
|
||||
@@ -39,6 +63,22 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
infoDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
},
|
||||
confirmDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
},
|
||||
club: {
|
||||
name: '',
|
||||
},
|
||||
@@ -47,13 +87,40 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = buildInfoConfig({ title, message, details, type });
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info', options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = buildConfirmConfig({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
async loadClub() {
|
||||
try {
|
||||
const response = await apiClient.get(`/clubs/${this.currentClub}`);
|
||||
this.club = response.data;
|
||||
this.accessAllowed = true;
|
||||
} catch (error) {
|
||||
alert('Zugriff auf den Verein nicht gestattet');
|
||||
const message = safeErrorMessage(error, 'Zugriff auf den Verein nicht gestattet.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
},
|
||||
async loadOpenRequests() {
|
||||
@@ -61,13 +128,18 @@ export default {
|
||||
const response = await apiClient.get(`/clubmembers/notapproved/${this.currentClub}`);
|
||||
this.openRequests = response.data;
|
||||
} catch (error) {
|
||||
alert('Fehler beim Laden der offenen Anfragen');
|
||||
this.showInfo('Fehler', 'Fehler beim Laden der offenen Anfragen', '', 'error');
|
||||
}
|
||||
},
|
||||
async requestAccess() {
|
||||
const response = await apiClient.get(`/clubs/request/${this.currentClub}`);
|
||||
if (response.status === 200) {
|
||||
alert('Zugriff wurde angefragt');
|
||||
try {
|
||||
const response = await apiClient.get(`/clubs/request/${this.currentClub}`);
|
||||
if (response.status === 200) {
|
||||
await this.showInfo('Hinweis', 'Zugriff wurde angefragt.', '', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = safeErrorMessage(error, 'Zugriffsanfrage konnte nicht gestellt werden.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
},
|
||||
labelGender(g) {
|
||||
|
||||
@@ -4,24 +4,90 @@
|
||||
<label>Name des Vereins: <input type="text" v-model="clubName" /></label>
|
||||
<button @click="createClub">Verein anlegen</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex/dist/vuex.cjs.js';
|
||||
import apiClient from '../apiClient';
|
||||
import { mapActions } from 'vuex';
|
||||
import apiClient from '../apiClient.js';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage } from '../utils/dialogUtils.js';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
export default {
|
||||
name: "CreateClubView",
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
infoDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
},
|
||||
confirmDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
},
|
||||
clubName: '',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = buildInfoConfig({ title, message, details, type });
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info', options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = buildConfirmConfig({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
...mapActions(['setClubs', 'setCurrentClub']),
|
||||
async createClub() {
|
||||
if (this.clubName.trim().length < 3) {
|
||||
alert('Bitte gib dem Verein einen Aussagekräftigen Namen');
|
||||
await this.showInfo('Hinweis', 'Bitte gib dem Verein einen aussagekräftigen Namen.', '', 'warning');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -31,9 +97,10 @@ export default {
|
||||
this.setCurrentClub(newClub.data);
|
||||
} catch (error) {
|
||||
if (error.status === 409) {
|
||||
alert('Der Verein existiert bereits.');
|
||||
await this.showInfo('Hinweis', 'Der Verein existiert bereits.', '', 'info');
|
||||
} else {
|
||||
alert('Ein unbekannter Fehler ist aufgetreten.');
|
||||
const message = safeErrorMessage(error, 'Ein unbekannter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,30 +10,96 @@
|
||||
<p>Noch kein Konto? <router-link to="/register">Registrieren</router-link></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import { mapActions } from 'vuex';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage } from '../utils/dialogUtils.js';
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
infoDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
},
|
||||
confirmDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
},
|
||||
email: '',
|
||||
password: '',
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = buildInfoConfig({ title, message, details, type });
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info', options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = buildConfirmConfig({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
...mapActions(['login']),
|
||||
async executeLogin() {
|
||||
try {
|
||||
const response = await axios.post(`${import.meta.env.VITE_BACKEND}/api/auth/login`, { email: this.email, password: this.password }, {
|
||||
const response = await axios.post(`${import.meta.env.VITE_BACKEND || 'http://localhost:3005'}/api/auth/login`, { email: this.email, password: this.password }, {
|
||||
timeout: 5000,
|
||||
});
|
||||
await this.login({ token: response.data.token, username: this.email });
|
||||
this.$router.push('/');
|
||||
} catch (error) {
|
||||
alert('Login fehlgeschlagen');
|
||||
const message = safeErrorMessage(error, 'Login fehlgeschlagen. Bitte Zugangsdaten prüfen und erneut versuchen.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
777
frontend/src/views/LogsView.vue
Normal file
@@ -0,0 +1,777 @@
|
||||
<template>
|
||||
<div class="logs-view">
|
||||
<div class="header">
|
||||
<h1>System-Logs</h1>
|
||||
<p class="subtitle">Übersicht über alle API-Requests, Responses und Ausführungen</p>
|
||||
</div>
|
||||
|
||||
<div class="filters-section">
|
||||
<div class="filter-controls">
|
||||
<div class="filter-group">
|
||||
<label>Backend:</label>
|
||||
<select v-model="filters.backend" class="filter-select">
|
||||
<option value="">Alle</option>
|
||||
<option value="mytischtennis">myTischtennis</option>
|
||||
<option value="own">Eigenes Backend</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Log-Typ:</label>
|
||||
<select v-model="filters.logType" class="filter-select">
|
||||
<option value="">Alle</option>
|
||||
<option value="api_request">API-Requests</option>
|
||||
<option value="scheduler">Scheduler</option>
|
||||
<option value="cron_job">Cron-Jobs</option>
|
||||
<option value="manual">Manuelle Ausführungen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>HTTP-Methode:</label>
|
||||
<select v-model="filters.method" class="filter-select">
|
||||
<option value="">Alle</option>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Status:</label>
|
||||
<select v-model="filters.statusCode" class="filter-select">
|
||||
<option value="">Alle</option>
|
||||
<option value="200">200 - OK</option>
|
||||
<option value="400">400 - Bad Request</option>
|
||||
<option value="401">401 - Unauthorized</option>
|
||||
<option value="403">403 - Forbidden</option>
|
||||
<option value="404">404 - Not Found</option>
|
||||
<option value="500">500 - Server Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Pfad:</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="filters.path"
|
||||
placeholder="z.B. /api/diary"
|
||||
class="filter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Von:</label>
|
||||
<input type="date" v-model="filters.startDate" class="filter-input" />
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Bis:</label>
|
||||
<input type="date" v-model="filters.endDate" class="filter-input" />
|
||||
</div>
|
||||
|
||||
<button @click="applyFilters" class="btn-primary">Filter anwenden</button>
|
||||
<button @click="clearFilters" class="btn-secondary">Zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status-Banner -->
|
||||
<div v-if="lastLoadTime" class="status-banner" :class="statusBannerClass">
|
||||
<div class="status-content">
|
||||
<span class="status-icon">{{ statusIcon }}</span>
|
||||
<span class="status-text">
|
||||
<template v-if="!loading && !error">
|
||||
<strong>Erfolgreich geladen</strong> – {{ total }} Datensätze gefunden, {{ logs.length }} angezeigt
|
||||
</template>
|
||||
<template v-else-if="loading">
|
||||
Lade Logs...
|
||||
</template>
|
||||
<template v-else-if="error">
|
||||
<strong>Fehler:</strong> {{ error }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="status-time">{{ formatLastLoadTime(lastLoadTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !lastLoadTime" class="loading">Lade Logs...</div>
|
||||
<div v-else-if="error && !lastLoadTime" class="error">{{ error }}</div>
|
||||
<div v-else class="logs-content">
|
||||
<div class="logs-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Gesamt:</span>
|
||||
<span class="stat-value">{{ total }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Angezeigt:</span>
|
||||
<span class="stat-value">{{ logs.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logs-table-container">
|
||||
<table class="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<th>Typ</th>
|
||||
<th>Methode</th>
|
||||
<th>Pfad</th>
|
||||
<th>Status</th>
|
||||
<th>Ausführungszeit</th>
|
||||
<th>Fehler</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs" :key="log.id" :class="getLogRowClass(log)">
|
||||
<td>{{ formatDate(log.createdAt) }}</td>
|
||||
<td>
|
||||
<span class="log-type-badge" :class="`log-type-${log.logType}`">
|
||||
{{ getLogTypeLabel(log.logType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="log.method !== 'SCHEDULER'" class="method-badge" :class="`method-${log.method}`">
|
||||
{{ log.method }}
|
||||
</span>
|
||||
<span v-else class="scheduler-badge">⏰</span>
|
||||
</td>
|
||||
<td class="path-cell">{{ log.path }}</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="getStatusClass(log.statusCode)">
|
||||
{{ log.statusCode || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatExecutionTime(log.executionTime) }}</td>
|
||||
<td>
|
||||
<span v-if="log.errorMessage" class="error-indicator" :title="log.errorMessage">
|
||||
⚠️ {{ truncate(log.errorMessage, 50) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>
|
||||
<button @click="viewLogDetails(log)" class="btn-view">Details</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button
|
||||
@click="previousPage"
|
||||
:disabled="offset === 0"
|
||||
class="btn-pagination"
|
||||
>
|
||||
← Vorherige
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Seite {{ currentPage }} von {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
@click="nextPage"
|
||||
:disabled="offset + logs.length >= total"
|
||||
class="btn-pagination"
|
||||
>
|
||||
Nächste →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Details Dialog -->
|
||||
<InfoDialog
|
||||
v-model="logDetailsDialog.isOpen"
|
||||
:title="logDetailsDialog.title"
|
||||
:message="logDetailsDialog.message"
|
||||
:details="logDetailsDialog.details"
|
||||
:type="logDetailsDialog.type"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import apiClient from '../apiClient.js';
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'LogsView',
|
||||
components: {
|
||||
InfoDialog
|
||||
},
|
||||
setup() {
|
||||
const logs = ref([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const limit = ref(50);
|
||||
const offset = ref(0);
|
||||
const lastLoadTime = ref(null);
|
||||
|
||||
const filters = ref({
|
||||
backend: '',
|
||||
logType: '',
|
||||
method: '',
|
||||
statusCode: '',
|
||||
path: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
const logDetailsDialog = ref({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
});
|
||||
|
||||
const currentPage = computed(() => Math.floor(offset.value / limit.value) + 1);
|
||||
const totalPages = computed(() => Math.ceil(total.value / limit.value));
|
||||
|
||||
const loadLogs = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
limit: limit.value,
|
||||
offset: offset.value,
|
||||
...filters.value
|
||||
};
|
||||
|
||||
// Convert backend filter to path filter
|
||||
if (params.backend === 'mytischtennis') {
|
||||
params.path = '/mytischtennis';
|
||||
delete params.backend;
|
||||
} else if (params.backend === 'own') {
|
||||
// Exclude myTischtennis paths
|
||||
params.path = 'NOT:/mytischtennis';
|
||||
delete params.backend;
|
||||
} else {
|
||||
delete params.backend;
|
||||
}
|
||||
|
||||
// Remove empty filters
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] === '' || params[key] === null) {
|
||||
delete params[key];
|
||||
}
|
||||
});
|
||||
|
||||
const response = await apiClient.get('/logs', { params });
|
||||
|
||||
if (response.data.success) {
|
||||
logs.value = response.data.data.logs;
|
||||
total.value = response.data.data.total;
|
||||
error.value = null;
|
||||
lastLoadTime.value = new Date();
|
||||
} else {
|
||||
error.value = 'Fehler beim Laden der Logs';
|
||||
lastLoadTime.value = new Date();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading logs:', err);
|
||||
error.value = err.response?.data?.error || 'Fehler beim Laden der Logs';
|
||||
lastLoadTime.value = new Date();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
offset.value = 0;
|
||||
loadLogs();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
filters.value = {
|
||||
backend: '',
|
||||
logType: '',
|
||||
method: '',
|
||||
statusCode: '',
|
||||
path: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
};
|
||||
offset.value = 0;
|
||||
loadLogs();
|
||||
};
|
||||
|
||||
const previousPage = () => {
|
||||
if (offset.value >= limit.value) {
|
||||
offset.value -= limit.value;
|
||||
loadLogs();
|
||||
}
|
||||
};
|
||||
|
||||
const nextPage = () => {
|
||||
if (offset.value + logs.value.length < total.value) {
|
||||
offset.value += limit.value;
|
||||
loadLogs();
|
||||
}
|
||||
};
|
||||
|
||||
const viewLogDetails = async (log) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/logs/${log.id}`);
|
||||
|
||||
if (response.data.success) {
|
||||
const logDetails = response.data.data;
|
||||
logDetailsDialog.value = {
|
||||
isOpen: true,
|
||||
title: `Log-Details #${log.id}`,
|
||||
message: `${logDetails.method} ${logDetails.path}`,
|
||||
details: formatLogDetails(logDetails),
|
||||
type: logDetails.statusCode >= 400 ? 'error' : 'info'
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading log details:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatLogDetails = (log) => {
|
||||
let details = `Zeit: ${formatDate(log.createdAt)}\n`;
|
||||
details += `Typ: ${getLogTypeLabel(log.logType)}\n`;
|
||||
details += `Methode: ${log.method}\n`;
|
||||
details += `Pfad: ${log.path}\n`;
|
||||
|
||||
if (log.statusCode) {
|
||||
details += `Status: ${log.statusCode}\n`;
|
||||
}
|
||||
|
||||
if (log.executionTime) {
|
||||
details += `Ausführungszeit: ${formatExecutionTime(log.executionTime)}\n`;
|
||||
}
|
||||
|
||||
if (log.ipAddress) {
|
||||
details += `IP: ${log.ipAddress}\n`;
|
||||
}
|
||||
|
||||
if (log.schedulerJobType) {
|
||||
details += `Scheduler-Job: ${log.schedulerJobType}\n`;
|
||||
}
|
||||
|
||||
if (log.errorMessage) {
|
||||
details += `\nFehler:\n${log.errorMessage}\n`;
|
||||
}
|
||||
|
||||
if (log.requestBody) {
|
||||
details += `\nRequest Body:\n${log.requestBody}\n`;
|
||||
}
|
||||
|
||||
if (log.responseBody) {
|
||||
details += `\nResponse Body:\n${log.responseBody}\n`;
|
||||
}
|
||||
|
||||
return details;
|
||||
};
|
||||
|
||||
const 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',
|
||||
second: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatExecutionTime = (time) => {
|
||||
if (!time) return '-';
|
||||
if (time < 1000) return `${time}ms`;
|
||||
return `${(time / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
const getLogTypeLabel = (type) => {
|
||||
const labels = {
|
||||
api_request: 'API-Request',
|
||||
scheduler: 'Scheduler',
|
||||
cron_job: 'Cron-Job',
|
||||
manual: 'Manuell'
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const getLogRowClass = (log) => {
|
||||
return {
|
||||
'log-error': log.statusCode >= 400,
|
||||
'log-success': log.statusCode >= 200 && log.statusCode < 300,
|
||||
'log-scheduler': log.logType === 'scheduler'
|
||||
};
|
||||
};
|
||||
|
||||
const getStatusClass = (statusCode) => {
|
||||
if (!statusCode) return 'status-unknown';
|
||||
if (statusCode >= 200 && statusCode < 300) return 'status-success';
|
||||
if (statusCode >= 400 && statusCode < 500) return 'status-client-error';
|
||||
if (statusCode >= 500) return 'status-server-error';
|
||||
return 'status-info';
|
||||
};
|
||||
|
||||
const truncate = (str, maxLen) => {
|
||||
if (!str) return '';
|
||||
return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
|
||||
};
|
||||
|
||||
const statusBannerClass = computed(() => {
|
||||
if (loading.value) return 'status-loading';
|
||||
if (error.value) return 'status-error';
|
||||
return 'status-success';
|
||||
});
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
if (loading.value) return '⏳';
|
||||
if (error.value) return '❌';
|
||||
return '✅';
|
||||
});
|
||||
|
||||
const formatLastLoadTime = (date) => {
|
||||
if (!date) return '';
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diff < 5) return 'gerade eben';
|
||||
if (diff < 60) return `vor ${diff} Sekunden`;
|
||||
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Minuten`;
|
||||
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
filters,
|
||||
logDetailsDialog,
|
||||
currentPage,
|
||||
totalPages,
|
||||
applyFilters,
|
||||
clearFilters,
|
||||
previousPage,
|
||||
nextPage,
|
||||
viewLogDetails,
|
||||
formatDate,
|
||||
formatExecutionTime,
|
||||
getLogTypeLabel,
|
||||
getLogRowClass,
|
||||
getStatusClass,
|
||||
truncate,
|
||||
lastLoadTime,
|
||||
statusBannerClass,
|
||||
statusIcon,
|
||||
formatLastLoadTime
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-view {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary, #666);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: #dcfce7;
|
||||
border-color: #16a34a;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #fee2e2;
|
||||
border-color: #dc2626;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.status-loading {
|
||||
background: #fef3c7;
|
||||
border-color: #f59e0b;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.status-time {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
background: var(--background-light, #f8f9fa);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
.filter-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.logs-stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.logs-table-container {
|
||||
overflow-x: auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.logs-table thead {
|
||||
background: var(--background-light, #f8f9fa);
|
||||
}
|
||||
|
||||
.logs-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.logs-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.logs-table tbody tr:hover {
|
||||
background: var(--background-light, #f8f9fa);
|
||||
}
|
||||
|
||||
.log-error {
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.log-success {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.log-scheduler {
|
||||
background: #fefce8;
|
||||
}
|
||||
|
||||
.log-type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-type-api_request {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.log-type-scheduler {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.log-type-cron_job {
|
||||
background: #fce7f3;
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.method-GET {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.method-POST {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.method-PUT {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.method-DELETE {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-client-error {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-server-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.path-cell {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error-indicator {
|
||||
color: #dc2626;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn-pagination {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-pagination:hover:not(:disabled) {
|
||||
background: var(--background-light, #f8f9fa);
|
||||
}
|
||||
|
||||
.btn-pagination:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
1287
frontend/src/views/MemberTransferSettingsView.vue
Normal file
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class="info-row">
|
||||
<label>Passwort gespeichert:</label>
|
||||
<span>{{ account.savePassword ? 'Ja' : 'Nein' }}</span>
|
||||
<span>{{ accountStatus && accountStatus.hasPassword ? 'Ja' : 'Nein' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="account.clubId">
|
||||
@@ -34,12 +34,75 @@
|
||||
<span>{{ formatDate(account.lastLoginAttempt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="account.lastUpdateRatings">
|
||||
<label>Letzter Abruf:</label>
|
||||
<span>{{ formatDate(account.lastUpdateRatings) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="account.autoUpdateRatings !== undefined">
|
||||
<label>Automatische Updates:</label>
|
||||
<span>{{ account.autoUpdateRatings ? 'Aktiviert' : 'Deaktiviert' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="btn-primary" @click="openEditDialog">Account bearbeiten</button>
|
||||
<button class="btn-secondary" @click="testConnection">Erneut einloggen</button>
|
||||
<button class="btn-info" @click="openHistoryDialog" v-if="account">Update-History</button>
|
||||
<button class="btn-danger" @click="deleteAccount">Account trennen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fetch Statistics Section -->
|
||||
<div class="info-section fetch-stats-section" v-if="account">
|
||||
<h2>Datenabruf-Statistiken</h2>
|
||||
|
||||
<div v-if="loadingStats" class="loading-stats">Lade Statistiken...</div>
|
||||
|
||||
<div v-else-if="latestFetches" class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-content">
|
||||
<h3>Spielerwertungen</h3>
|
||||
<div v-if="latestFetches.ratings">
|
||||
<p class="stat-date">{{ formatDateRelative(latestFetches.ratings.lastFetch) }}</p>
|
||||
<p class="stat-detail">{{ latestFetches.ratings.recordsProcessed }} Spieler aktualisiert</p>
|
||||
<p class="stat-time" v-if="latestFetches.ratings.executionTime">{{ latestFetches.ratings.executionTime }}ms</p>
|
||||
</div>
|
||||
<p v-else class="stat-never">Noch nie abgerufen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🏓</div>
|
||||
<div class="stat-content">
|
||||
<h3>Spielergebnisse</h3>
|
||||
<div v-if="latestFetches.match_results">
|
||||
<p class="stat-date">{{ formatDateRelative(latestFetches.match_results.lastFetch) }}</p>
|
||||
<p class="stat-detail">{{ latestFetches.match_results.recordsProcessed }} Ergebnisse</p>
|
||||
<p class="stat-time" v-if="latestFetches.match_results.executionTime">{{ latestFetches.match_results.executionTime }}ms</p>
|
||||
</div>
|
||||
<p v-else class="stat-never">Noch nie abgerufen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📋</div>
|
||||
<div class="stat-content">
|
||||
<h3>Ligatabellen</h3>
|
||||
<div v-if="latestFetches.league_table">
|
||||
<p class="stat-date">{{ formatDateRelative(latestFetches.league_table.lastFetch) }}</p>
|
||||
<p class="stat-detail">{{ latestFetches.league_table.recordsProcessed }} Teams</p>
|
||||
<p class="stat-time" v-if="latestFetches.league_table.executionTime">{{ latestFetches.league_table.executionTime }}ms</p>
|
||||
</div>
|
||||
<p v-else class="stat-never">Noch nie abgerufen</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-secondary refresh-stats-btn" @click="loadLatestFetches">
|
||||
🔄 Statistiken aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-account">
|
||||
@@ -66,36 +129,129 @@
|
||||
@close="closeDialog"
|
||||
@saved="onAccountSaved"
|
||||
/>
|
||||
|
||||
<!-- History Dialog -->
|
||||
<MyTischtennisHistoryDialog
|
||||
v-if="showHistoryDialog"
|
||||
@close="closeHistoryDialog"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorMessages.js';
|
||||
import MyTischtennisDialog from '../components/MyTischtennisDialog.vue';
|
||||
import MyTischtennisHistoryDialog from '../components/MyTischtennisHistoryDialog.vue';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
export default {
|
||||
name: 'MyTischtennisAccount',
|
||||
components: {
|
||||
MyTischtennisDialog
|
||||
MyTischtennisDialog,
|
||||
MyTischtennisHistoryDialog,
|
||||
InfoDialog,
|
||||
ConfirmDialog
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
infoDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
},
|
||||
confirmDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
},
|
||||
loading: true,
|
||||
loadingStats: false,
|
||||
account: null,
|
||||
showDialog: false
|
||||
accountStatus: null,
|
||||
latestFetches: null,
|
||||
showDialog: false,
|
||||
showHistoryDialog: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadAccount();
|
||||
this.loadLatestFetches();
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type
|
||||
};
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info') {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
async loadAccount() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const response = await apiClient.get('/mytischtennis/account');
|
||||
this.account = response.data.account;
|
||||
const [accountResponse, statusResponse] = await Promise.all([
|
||||
apiClient.get('/mytischtennis/account'),
|
||||
apiClient.get('/mytischtennis/status')
|
||||
]);
|
||||
this.account = accountResponse.data.account;
|
||||
this.accountStatus = statusResponse.data;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Accounts:', error);
|
||||
this.account = null;
|
||||
this.accountStatus = null;
|
||||
this.$store.dispatch('showMessage', {
|
||||
text: 'Fehler beim Laden des myTischtennis-Accounts',
|
||||
type: 'error'
|
||||
@@ -113,6 +269,14 @@ export default {
|
||||
this.showDialog = false;
|
||||
},
|
||||
|
||||
openHistoryDialog() {
|
||||
this.showHistoryDialog = true;
|
||||
},
|
||||
|
||||
closeHistoryDialog() {
|
||||
this.showHistoryDialog = false;
|
||||
},
|
||||
|
||||
async onAccountSaved() {
|
||||
this.closeDialog();
|
||||
await this.loadAccount();
|
||||
@@ -131,11 +295,14 @@ export default {
|
||||
});
|
||||
await this.loadAccount(); // Aktualisiere Account-Daten inkl. clubId, fedNickname
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Login fehlgeschlagen';
|
||||
const message = getSafeErrorMessage(error, 'Login fehlgeschlagen');
|
||||
|
||||
if (error.response?.status === 400 && message.includes('Kein Passwort gespeichert')) {
|
||||
// Passwort-Dialog öffnen
|
||||
this.showDialog = true;
|
||||
this.showInfo('Passwort benötigt', message, '', 'warning');
|
||||
} else {
|
||||
this.showInfo('Login fehlgeschlagen', message, '', 'error');
|
||||
}
|
||||
|
||||
this.$store.dispatch('showMessage', {
|
||||
@@ -146,23 +313,35 @@ export default {
|
||||
},
|
||||
|
||||
async deleteAccount() {
|
||||
if (!confirm('Möchten Sie die Verknüpfung zum myTischtennis-Account wirklich trennen?')) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await this.showConfirm(
|
||||
'Account trennen',
|
||||
'Möchten Sie die Verknüpfung zum myTischtennis-Account wirklich trennen?',
|
||||
'',
|
||||
'danger'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await apiClient.delete('/mytischtennis/account');
|
||||
this.account = null;
|
||||
this.$store.dispatch('showMessage', {
|
||||
text: 'myTischtennis-Account erfolgreich getrennt',
|
||||
type: 'success'
|
||||
});
|
||||
this.accountStatus = null;
|
||||
this.showInfo('Erfolg', 'myTischtennis-Account erfolgreich getrennt', '', 'success');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Accounts:', error);
|
||||
this.$store.dispatch('showMessage', {
|
||||
text: 'Fehler beim Trennen des Accounts',
|
||||
type: 'error'
|
||||
});
|
||||
this.showInfo('Fehler', 'Fehler beim Trennen des Accounts', error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async loadLatestFetches() {
|
||||
this.loadingStats = true;
|
||||
try {
|
||||
const response = await apiClient.get('/mytischtennis/latest-fetches');
|
||||
this.latestFetches = response.data.latestFetches;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Fetch-Statistiken:', error);
|
||||
} finally {
|
||||
this.loadingStats = false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -176,6 +355,31 @@ export default {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
formatDateRelative(dateString) {
|
||||
if (!dateString) return 'Nie';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Gerade eben';
|
||||
if (diffMins < 60) return `vor ${diffMins} Min.`;
|
||||
if (diffHours < 24) return `vor ${diffHours} Std.`;
|
||||
if (diffDays === 1) return 'Gestern';
|
||||
if (diffDays < 7) return `vor ${diffDays} Tagen`;
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -302,6 +506,85 @@ h1 {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
/* Fetch Statistics */
|
||||
.fetch-stats-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.loading-stats {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-content h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-date {
|
||||
font-weight: 600;
|
||||
color: #28a745;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.stat-time {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.stat-never {
|
||||
font-style: italic;
|
||||
color: #999;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.refresh-stats-btn {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
@@ -310,5 +593,14 @@ h1 {
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background-color: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
background-color: #138496;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -173,7 +173,28 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -249,8 +270,50 @@
|
||||
<span v-else>{{ item.placement || '–' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
<tr v-if="!participantsGroups.length">
|
||||
<td colspan="6"><em>Keine Einträge für den gewählten Filter.</em></td>
|
||||
</tr>
|
||||
@@ -301,6 +364,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitglied</th>
|
||||
<th>Turnier</th>
|
||||
<th>Konkurrenz</th>
|
||||
<th>Datum</th>
|
||||
<th>Platzierung</th>
|
||||
@@ -309,6 +373,7 @@
|
||||
<tbody>
|
||||
<tr v-for="row in clubParticipationRows()" :key="row.key">
|
||||
<td>{{ row.memberName }}</td>
|
||||
<td>{{ row.tournamentName }}</td>
|
||||
<td>{{ row.competitionName }}</td>
|
||||
<td>{{ row.date }}</td>
|
||||
<td>{{ row.placement || '–' }}</td>
|
||||
@@ -318,56 +383,82 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMemberDialog" class="modal-overlay" @click.self="closeMemberDialog">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Mitglieder auswählen</h3>
|
||||
</div>
|
||||
<div class="modal-controls">
|
||||
<button class="btn-secondary" @click="selectAllMembers">Alle auswählen</button>
|
||||
<button class="btn-secondary" @click="deselectAllMembers">Alle abwählen</button>
|
||||
<div style="flex:1;"></div>
|
||||
<button class="btn-secondary" @click="closeMemberDialog">Schließen</button>
|
||||
<button class="btn-primary" :disabled="!selectedMemberIds.length" @click="generateMembersPdfAndClose">PDF erzeugen</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="dialog-layout">
|
||||
<div class="dialog-col members-col">
|
||||
<div class="checkbox-column">
|
||||
<label v-for="m in activeMembers" :key="m.id" class="check-item">
|
||||
<input type="checkbox" :value="m.id" v-model="selectedMemberIds" @change="selectedMemberIdForDialog = m.id" />
|
||||
<span :class="{ active: selectedMemberIdForDialog === m.id }">{{ m.firstName }} {{ m.lastName }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-col recommendations-col" v-if="selectedMemberInDialog">
|
||||
<h4>Empfehlungen</h4>
|
||||
<div v-if="selectedMemberCompetitions.length">
|
||||
<label v-for="row in selectedMemberCompetitions" :key="row.key" class="check-item">
|
||||
<input type="checkbox" :checked="isRecommended(selectedMemberInDialog.id, row.key)" @change="toggleRecommendation(selectedMemberInDialog.id, row.key)" />
|
||||
<span>{{ row.name }} — {{ row.date }} {{ row.time }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else>
|
||||
<em>Keine passenden Wettbewerbe gefunden.</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Member Selection Dialog -->
|
||||
<MemberSelectionDialog
|
||||
v-model="showMemberDialog"
|
||||
:members="activeMembers"
|
||||
v-model:selected-ids="selectedMemberIds"
|
||||
v-model:active-member-id="selectedMemberIdForDialog"
|
||||
:recommendations="selectedMemberCompetitions"
|
||||
:recommended-keys="getRecommendedKeys()"
|
||||
@select-all="selectAllMembers"
|
||||
@deselect-all="deselectAllMembers"
|
||||
@toggle-member="handleMemberToggle"
|
||||
@toggle-recommendation="handleRecommendationToggle"
|
||||
@generate-pdf="generateMembersPdfAndClose"
|
||||
@close="closeMemberDialog"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
import { mapGetters } from 'vuex';
|
||||
import apiClient from '../apiClient.js';
|
||||
import { getSafeErrorMessage, getSafeMessage } from '../utils/errorMessages.js';
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage } from '../utils/dialogUtils.js';
|
||||
import PDFGenerator from '../components/PDFGenerator.js';
|
||||
|
||||
import BaseDialog from '../components/BaseDialog.vue';
|
||||
import MemberSelectionDialog from '../components/MemberSelectionDialog.vue';
|
||||
export default {
|
||||
name: 'OfficialTournaments',
|
||||
components: {
|
||||
InfoDialog,
|
||||
ConfirmDialog,
|
||||
BaseDialog,
|
||||
MemberSelectionDialog
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
infoDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
},
|
||||
confirmDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
},
|
||||
selectedFile: null,
|
||||
uploadedId: null,
|
||||
parsed: null,
|
||||
@@ -510,6 +601,32 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = buildInfoConfig({ title, message, details, type });
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info', options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = buildConfirmConfig({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
switchTopTab(tab) { this.topActiveTab = tab; if (tab === 'participations') this.loadClubParticipations(); },
|
||||
compareMembers(a, b) {
|
||||
const fnA = String(a.firstName || '');
|
||||
@@ -717,6 +834,7 @@ export default {
|
||||
rows.push({
|
||||
key: `club-${t.tournamentId}-${e.competitionId}-${e.memberId}`,
|
||||
memberName: e.memberName,
|
||||
tournamentName: t.tournamentName || t.title || `Turnier #${t.tournamentId}`,
|
||||
competitionName: e.competitionName,
|
||||
date: e.date || t.startDate || '–',
|
||||
placement: e.placement || null,
|
||||
@@ -737,8 +855,29 @@ export default {
|
||||
const m = (this.members || []).find(x => String(x.id) === String(id));
|
||||
return m ? `${m.firstName} ${m.lastName}` : `#${id}`;
|
||||
},
|
||||
onFile(e) {
|
||||
this.selectedFile = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
||||
async onFile(e) {
|
||||
const file = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
||||
if (!file) {
|
||||
this.selectedFile = null;
|
||||
return;
|
||||
}
|
||||
const maxSize = 10 * 1024 * 1024; // 10 MB
|
||||
const extension = file.name.includes('.') ? file.name.split('.').pop().toLowerCase() : '';
|
||||
const isPdf = extension === 'pdf';
|
||||
const allowedMime = ['application/pdf', 'application/octet-stream'];
|
||||
if (!isPdf || (file.type && !allowedMime.includes(file.type))) {
|
||||
this.selectedFile = null;
|
||||
e.target.value = '';
|
||||
await this.showInfo('Ungültige Datei', 'Bitte wählen Sie eine PDF-Datei aus.', '', 'warning');
|
||||
return;
|
||||
}
|
||||
if (file.size > maxSize) {
|
||||
this.selectedFile = null;
|
||||
e.target.value = '';
|
||||
await this.showInfo('Datei zu groß', 'Die PDF darf maximal 10 MB groß sein.', '', 'warning');
|
||||
return;
|
||||
}
|
||||
this.selectedFile = file;
|
||||
},
|
||||
isExpanded(c, idx) {
|
||||
return !!this.expanded[String(idx)];
|
||||
@@ -751,12 +890,18 @@ export default {
|
||||
if (!this.selectedFile) return;
|
||||
const fd = new FormData();
|
||||
fd.append('pdf', this.selectedFile);
|
||||
const r = await apiClient.post(`/official-tournaments/${this.currentClub}/upload`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
this.uploadedId = r.data.id;
|
||||
await this.reload();
|
||||
await this.loadList();
|
||||
try {
|
||||
const response = await apiClient.post(`/official-tournaments/${this.currentClub}/upload`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
this.uploadedId = response.data.id;
|
||||
await this.reload();
|
||||
await this.loadList();
|
||||
await this.showInfo('Erfolg', 'PDF erfolgreich hochgeladen.', '', 'success');
|
||||
} catch (error) {
|
||||
const message = safeErrorMessage(error, 'Fehler beim Hochladen der PDF.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
},
|
||||
async reload() {
|
||||
if (!this.uploadedId) return;
|
||||
@@ -849,7 +994,7 @@ export default {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren des Status:', error);
|
||||
alert('Fehler beim Aktualisieren des Status: ' + (error.response?.data?.error || error.message));
|
||||
this.showInfo('Fehler', 'Fehler beim Aktualisieren des Status', getSafeErrorMessage(error), 'error');
|
||||
}
|
||||
},
|
||||
async updatePlacement(item, value) {
|
||||
@@ -860,7 +1005,7 @@ export default {
|
||||
await this.saveParticipation(competitionId, memberId);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren der Platzierung:', error);
|
||||
alert('Fehler beim Aktualisieren der Platzierung: ' + (error.response?.data?.error || error.message));
|
||||
this.showInfo('Fehler', 'Fehler beim Aktualisieren der Platzierung', getSafeErrorMessage(error), 'error');
|
||||
}
|
||||
},
|
||||
async updateStatusForCompetition(competition, member, action) {
|
||||
@@ -885,7 +1030,7 @@ export default {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren des Status:', error);
|
||||
alert('Fehler beim Aktualisieren des Status: ' + (error.response?.data?.error || error.message));
|
||||
this.showInfo('Fehler', 'Fehler beim Aktualisieren des Status', getSafeErrorMessage(error), 'error');
|
||||
}
|
||||
},
|
||||
// Auswahl Helfer + PDF-Generierung
|
||||
@@ -898,6 +1043,32 @@ export default {
|
||||
},
|
||||
selectAllMembers() { this.selectedMemberIds = this.activeMembers.map(m => m.id); },
|
||||
deselectAllMembers() { this.selectedMemberIds = []; },
|
||||
handleMemberToggle({ memberId, checked }) {
|
||||
this.selectedMemberIdForDialog = memberId;
|
||||
if (checked && !this.selectedMemberIds.includes(memberId)) {
|
||||
this.selectedMemberIds.push(memberId);
|
||||
} else if (!checked) {
|
||||
this.selectedMemberIds = this.selectedMemberIds.filter(id => id !== memberId);
|
||||
}
|
||||
},
|
||||
handleRecommendationToggle({ memberId, key, checked }) {
|
||||
if (checked) {
|
||||
if (!this.memberRecommendations[memberId]) {
|
||||
this.$set ? this.$set(this.memberRecommendations, memberId, new Set()) : (this.memberRecommendations[memberId] = new Set());
|
||||
}
|
||||
this.memberRecommendations[memberId].add(key);
|
||||
} else {
|
||||
if (this.memberRecommendations[memberId]) {
|
||||
this.memberRecommendations[memberId].delete(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
getRecommendedKeys() {
|
||||
if (!this.selectedMemberIdForDialog || !this.memberRecommendations[this.selectedMemberIdForDialog]) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(this.memberRecommendations[this.selectedMemberIdForDialog]);
|
||||
},
|
||||
splitDateTime(str) {
|
||||
if (!str) return { date: '–', time: '–' };
|
||||
const s = String(str);
|
||||
@@ -1122,7 +1293,13 @@ export default {
|
||||
},
|
||||
|
||||
async removeTournament(t) {
|
||||
if (!confirm(`Turnier wirklich löschen?\n${t.title || 'Ohne Titel'} (ID ${t.id})`)) return;
|
||||
const confirmed = await this.showConfirm(
|
||||
'Turnier löschen',
|
||||
`Turnier wirklich löschen?\n${t.title || 'Ohne Titel'} (ID ${t.id})`,
|
||||
'',
|
||||
'danger'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
await apiClient.delete(`/official-tournaments/${this.currentClub}/${t.id}`);
|
||||
if (String(this.uploadedId) === String(t.id)) {
|
||||
this.parsed = null;
|
||||
@@ -1330,6 +1507,25 @@ th, td { border-bottom: 1px solid var(--border-color); padding: 0.5rem; text-ali
|
||||
font-weight: 700;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/* Dialog-spezifische Styles */
|
||||
.member-dialog-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.member-dialog-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.member-dialog-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -28,16 +28,56 @@
|
||||
<p>Keine ausstehenden Benutzeranfragen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import apiClient from '../apiClient.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorMessages.js';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
export default {
|
||||
name: 'PendingApprovalsView',
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
infoDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
},
|
||||
confirmDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
},
|
||||
pendingUsers: [],
|
||||
};
|
||||
},
|
||||
@@ -48,12 +88,49 @@ export default {
|
||||
await this.loadPendingApprovals();
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type
|
||||
};
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info') {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
async loadPendingApprovals() {
|
||||
try {
|
||||
const response = await apiClient.get(`/clubs/pending/${this.currentClub}`);
|
||||
this.pendingUsers = response.data.map(entry => entry.user);
|
||||
} catch (error) {
|
||||
alert('Fehler beim Laden der ausstehenden Anfragen');
|
||||
if (error.response?.status === 403) {
|
||||
await this.showInfo('Keine Berechtigung', 'Sie haben keine Berechtigung, Freigaben zu verwalten.', 'Nur Administratoren können Mitgliedsanfragen bearbeiten.', 'error');
|
||||
this.$router.push('/');
|
||||
} else {
|
||||
this.showInfo('Fehler', 'Fehler beim Laden der ausstehenden Anfragen', getSafeErrorMessage(error), 'error');
|
||||
}
|
||||
}
|
||||
},
|
||||
async approveUser(userId) {
|
||||
@@ -64,7 +141,7 @@ export default {
|
||||
});
|
||||
this.pendingUsers = this.pendingUsers.filter(user => user.id !== userId);
|
||||
} catch (error) {
|
||||
alert('Fehler beim Genehmigen des Benutzers');
|
||||
this.showInfo('Fehler', 'Fehler beim Genehmigen des Benutzers', '', 'error');
|
||||
}
|
||||
},
|
||||
async rejectUser(userId) {
|
||||
@@ -75,7 +152,7 @@ export default {
|
||||
});
|
||||
this.pendingUsers = this.pendingUsers.filter(user => user.id !== userId);
|
||||
} catch (error) {
|
||||
alert('Fehler beim Ablehnen des Benutzers');
|
||||
this.showInfo('Fehler', 'Fehler beim Ablehnen des Benutzers', '', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
957
frontend/src/views/PermissionsView.vue
Normal file
@@ -0,0 +1,957 @@
|
||||
<template>
|
||||
<div class="permissions-view">
|
||||
<div class="header">
|
||||
<h1>Berechtigungsverwaltung</h1>
|
||||
<p class="subtitle">Verwalten Sie die Zugriffsrechte für Clubmitglieder</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Lade Mitglieder...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else class="permissions-content">
|
||||
<!-- Role Legend -->
|
||||
<div class="role-legend">
|
||||
<h3>Verfügbare Rollen</h3>
|
||||
<div class="roles-grid">
|
||||
<div v-for="role in availableRoles" :key="role.value" class="role-card">
|
||||
<div class="role-name">{{ role.label }}</div>
|
||||
<div class="role-description">{{ role.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members Table -->
|
||||
<div class="members-table">
|
||||
<h3>Clubmitglieder</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th v-if="!isReadOnly">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in members" :key="member.userId">
|
||||
<td>{{ member.user?.email || 'N/A' }}</td>
|
||||
<td>
|
||||
<select
|
||||
v-if="!member.isOwner && !isReadOnly"
|
||||
v-model="member.role"
|
||||
@change="updateMemberRole(member)"
|
||||
class="role-select"
|
||||
>
|
||||
<option v-for="role in availableRoles" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-else class="role-badge" :class="`role-${member.role}`">
|
||||
{{ getRoleLabel(member.role) }}
|
||||
<span v-if="member.isOwner" class="owner-badge">👑 Ersteller</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="member.approved !== false"
|
||||
class="status-badge status-active"
|
||||
:class="{ 'clickable': !member.isOwner && !isReadOnly }"
|
||||
@click="!member.isOwner && !isReadOnly ? toggleMemberStatus(member) : null"
|
||||
:title="!member.isOwner && !isReadOnly ? 'Klicken zum Deaktivieren' : ''"
|
||||
>
|
||||
✓ Aktiv
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="status-badge status-inactive"
|
||||
:class="{ 'clickable': !isReadOnly }"
|
||||
@click="!isReadOnly ? toggleMemberStatus(member) : null"
|
||||
:title="!isReadOnly ? 'Klicken zum Aktivieren' : ''"
|
||||
>
|
||||
✗ Deaktiviert
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="!isReadOnly">
|
||||
<button
|
||||
v-if="!member.isOwner"
|
||||
@click="openPermissionsDialog(member)"
|
||||
class="btn-small"
|
||||
>
|
||||
Anpassen
|
||||
</button>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Permissions Dialog -->
|
||||
<div v-if="selectedMember" class="dialog-overlay" @click.self="closePermissionsDialog">
|
||||
<div class="dialog-content">
|
||||
<div class="dialog-header">
|
||||
<h2>Berechtigungen für {{ selectedMember.user?.email }}</h2>
|
||||
<button @click="closePermissionsDialog" class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<p class="info-text">
|
||||
Basis-Rolle: <strong>{{ getRoleLabel(selectedMember.role) }}</strong><br>
|
||||
Hier können Sie individuelle Anpassungen vornehmen.
|
||||
</p>
|
||||
|
||||
<div class="permissions-grid">
|
||||
<div v-for="(resource, key) in permissionStructure" :key="key" class="permission-group">
|
||||
<div class="permission-group-header">
|
||||
<h4>{{ resource.label }}</h4>
|
||||
<button class="btn-reset" @click="resetResource(key)" :disabled="isReadOnly">Zurücksetzen</button>
|
||||
</div>
|
||||
<div class="permission-actions">
|
||||
<div v-for="action in resource.actions" :key="action" class="permission-row">
|
||||
<span class="permission-action-label">{{ getActionLabel(action) }}</span>
|
||||
<button
|
||||
class="perm-state"
|
||||
:class="stateClass(customPermissions[key][action], key, action)"
|
||||
@click="togglePermission(key, action)"
|
||||
:disabled="isReadOnly"
|
||||
>{{ stateLabel(customPermissions[key][action], key, action) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button @click="resetAll" class="btn-secondary" :disabled="isReadOnly">Alle zurücksetzen</button>
|
||||
<button @click="closePermissionsDialog" class="btn-secondary">Abbrechen</button>
|
||||
<button @click="saveCustomPermissions" class="btn-primary">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
:confirm-text="confirmDialog.confirmText"
|
||||
:cancel-text="confirmDialog.cancelText"
|
||||
:show-cancel="confirmDialog.showCancel"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import apiClient from '../apiClient.js';
|
||||
import { usePermissions } from '../composables/usePermissions.js';
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'PermissionsView',
|
||||
components: {
|
||||
InfoDialog,
|
||||
ConfirmDialog
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const { isOwner, isAdmin, can } = usePermissions();
|
||||
|
||||
const infoDialog = ref({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
});
|
||||
const confirmDialog = ref({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
confirmText: 'OK',
|
||||
cancelText: 'Abbrechen',
|
||||
showCancel: true,
|
||||
resolveCallback: null
|
||||
});
|
||||
|
||||
const showInfo = async (title, message, details = '', type = 'info') => {
|
||||
infoDialog.value = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type
|
||||
};
|
||||
};
|
||||
|
||||
const showConfirm = async (title, message, details = '', type = 'info', options = {}) => {
|
||||
return new Promise((resolve) => {
|
||||
confirmDialog.value = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
confirmText: options.confirmText || 'OK',
|
||||
cancelText: options.cancelText || 'Abbrechen',
|
||||
showCancel: options.showCancel !== false,
|
||||
resolveCallback: resolve
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmResult = (confirmed) => {
|
||||
if (confirmDialog.value.resolveCallback) {
|
||||
confirmDialog.value.resolveCallback(confirmed);
|
||||
confirmDialog.value.resolveCallback = null;
|
||||
}
|
||||
confirmDialog.value.isOpen = false;
|
||||
};
|
||||
|
||||
const currentClub = computed(() => store.getters.currentClub);
|
||||
const members = ref([]);
|
||||
const availableRoles = ref([]);
|
||||
const permissionStructure = ref({});
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const selectedMember = ref(null);
|
||||
const customPermissions = ref({});
|
||||
|
||||
const isReadOnly = computed(() => {
|
||||
return !can('permissions', 'write');
|
||||
});
|
||||
|
||||
const loadData = async (force = false) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// Load available roles
|
||||
const rolesResponse = await apiClient.get('/permissions/roles/available');
|
||||
availableRoles.value = rolesResponse.data;
|
||||
|
||||
// Load permission structure
|
||||
const structureResponse = await apiClient.get('/permissions/structure/all');
|
||||
permissionStructure.value = structureResponse.data;
|
||||
|
||||
// Load members with permissions
|
||||
const bust = force ? `?t=${Date.now()}` : '';
|
||||
const membersResponse = await apiClient.get(`/permissions/${currentClub.value}/members${bust}`);
|
||||
members.value = membersResponse.data;
|
||||
} catch (err) {
|
||||
console.error('Error loading permissions data:', err);
|
||||
|
||||
if (err.response?.status === 403) {
|
||||
error.value = 'Keine Berechtigung: Nur Administratoren können Berechtigungen verwalten.';
|
||||
// Redirect nach kurzer Verzögerung
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
error.value = err.response?.data?.error || 'Fehler beim Laden der Daten';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateMemberRole = async (member) => {
|
||||
try {
|
||||
await apiClient.put(
|
||||
`/permissions/${currentClub.value}/user/${member.userId}/role`,
|
||||
{ role: member.role }
|
||||
);
|
||||
|
||||
// Reload data to get updated permissions
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error('Error updating role:', err);
|
||||
await showInfo('Fehler', err.response?.data?.error || 'Fehler beim Aktualisieren der Rolle', '', 'error');
|
||||
// Reload to revert changes
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMemberStatus = async (member) => {
|
||||
const newStatus = member.approved === false ? true : false;
|
||||
const action = newStatus ? 'aktivieren' : 'deaktivieren';
|
||||
|
||||
const confirmed = await showConfirm(
|
||||
'Status ändern',
|
||||
`Möchten Sie ${member.user?.email} wirklich ${action}?`,
|
||||
'',
|
||||
'warning'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.put(
|
||||
`/permissions/${currentClub.value}/user/${member.userId}/status`,
|
||||
{ approved: newStatus }
|
||||
);
|
||||
|
||||
// Update local state - visual feedback happens automatically
|
||||
member.approved = newStatus;
|
||||
} catch (err) {
|
||||
console.error('Error updating status:', err);
|
||||
// Reload to revert changes on error
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const openPermissionsDialog = async (member) => {
|
||||
selectedMember.value = member;
|
||||
|
||||
// Load fresh data for this specific member to ensure we have the latest permissions
|
||||
try {
|
||||
const membersResponse = await apiClient.get(`/permissions/${currentClub.value}/members?t=${Date.now()}`);
|
||||
const freshMember = membersResponse.data.find(m => m.userId === member.userId);
|
||||
if (freshMember) {
|
||||
selectedMember.value = freshMember;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading fresh member data:', err);
|
||||
// Continue with existing data if fresh load fails
|
||||
}
|
||||
|
||||
// Initialize custom permissions from the stored custom permissions, not effective permissions
|
||||
customPermissions.value = {};
|
||||
for (const resource in permissionStructure.value) {
|
||||
customPermissions.value[resource] = {};
|
||||
const customPerms = selectedMember.value.permissions || {};
|
||||
for (const action of permissionStructure.value[resource].actions) {
|
||||
// Use custom permissions if they exist, otherwise undefined (inherit from role)
|
||||
customPermissions.value[resource][action] = customPerms[resource]?.[action];
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const closePermissionsDialog = () => {
|
||||
selectedMember.value = null;
|
||||
customPermissions.value = {};
|
||||
};
|
||||
|
||||
const saveCustomPermissions = async () => {
|
||||
try {
|
||||
// Only send permissions that are explicitly set (not undefined)
|
||||
const permissionsToSave = {};
|
||||
for (const resourceKey in customPermissions.value) {
|
||||
permissionsToSave[resourceKey] = {};
|
||||
for (const action in customPermissions.value[resourceKey]) {
|
||||
const value = customPermissions.value[resourceKey][action];
|
||||
if (value !== undefined) {
|
||||
permissionsToSave[resourceKey][action] = value;
|
||||
}
|
||||
}
|
||||
// Only include resource if it has at least one permission set
|
||||
if (Object.keys(permissionsToSave[resourceKey]).length === 0) {
|
||||
delete permissionsToSave[resourceKey];
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.put(
|
||||
`/permissions/${currentClub.value}/user/${selectedMember.value.userId}/permissions`,
|
||||
{ permissions: permissionsToSave }
|
||||
);
|
||||
|
||||
// Update local member data immediately
|
||||
const memberIndex = members.value.findIndex(m => m.userId === selectedMember.value.userId);
|
||||
if (memberIndex !== -1) {
|
||||
members.value[memberIndex].permissions = permissionsToSave;
|
||||
// Recalculate effective permissions
|
||||
const permService = { getEffectivePermissions: (uc) => {
|
||||
const rolePerms = getRolePermissions(uc.role);
|
||||
const customPerms = uc.permissions || {};
|
||||
const merged = JSON.parse(JSON.stringify(rolePerms));
|
||||
for (const resource in customPerms) {
|
||||
if (!merged[resource]) merged[resource] = {};
|
||||
merged[resource] = { ...merged[resource], ...customPerms[resource] };
|
||||
}
|
||||
return merged;
|
||||
}};
|
||||
members.value[memberIndex].effectivePermissions = permService.getEffectivePermissions(members.value[memberIndex]);
|
||||
}
|
||||
|
||||
closePermissionsDialog();
|
||||
// Hard reload from server to reflect saved values (cache-busting)
|
||||
await loadData(true);
|
||||
} catch (err) {
|
||||
console.error('Error saving permissions:', err);
|
||||
await showInfo('Fehler', err.response?.data?.error || 'Fehler beim Speichern der Berechtigungen', '', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (resourceKey, action) => {
|
||||
const current = customPermissions.value[resourceKey][action];
|
||||
const rolePermissions = getRolePermissions(selectedMember.value.role);
|
||||
const roleValue = rolePermissions[resourceKey]?.[action];
|
||||
|
||||
// Toggle between: role value -> opposite of role value -> role value
|
||||
if (current === undefined) {
|
||||
// Currently using role value, set to opposite
|
||||
customPermissions.value[resourceKey][action] = !roleValue;
|
||||
} else {
|
||||
// Currently overridden, reset to role value
|
||||
customPermissions.value[resourceKey][action] = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const resetResource = (resourceKey) => {
|
||||
for (const action of permissionStructure.value[resourceKey].actions) {
|
||||
customPermissions.value[resourceKey][action] = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
for (const resourceKey in permissionStructure.value) {
|
||||
resetResource(resourceKey);
|
||||
}
|
||||
};
|
||||
|
||||
const stateLabel = (val, resourceKey, action) => {
|
||||
if (val === true) return 'Erlaubt';
|
||||
if (val === false) return 'Verboten';
|
||||
|
||||
// Show role-based permission
|
||||
const rolePermissions = getRolePermissions(selectedMember.value.role);
|
||||
const roleValue = rolePermissions[resourceKey]?.[action];
|
||||
return roleValue ? 'Erlaubt' : 'Verboten';
|
||||
};
|
||||
|
||||
const stateClass = (val, resourceKey, action) => {
|
||||
// If undefined, it's using role value (inherit)
|
||||
if (val === undefined) {
|
||||
return 'state-inherit';
|
||||
}
|
||||
|
||||
// If explicitly set, show as override
|
||||
return val === true ? 'state-allow' : 'state-deny';
|
||||
};
|
||||
|
||||
const getRoleLabel = (roleValue) => {
|
||||
const role = availableRoles.value.find(r => r.value === roleValue);
|
||||
return role ? role.label : roleValue;
|
||||
};
|
||||
|
||||
const getRolePermissions = (role) => {
|
||||
// Role permissions mapping (should match backend)
|
||||
const rolePermissions = {
|
||||
admin: {
|
||||
diary: { read: true, write: true, delete: true },
|
||||
members: { read: true, write: true, delete: true },
|
||||
teams: { read: true, write: true, delete: true },
|
||||
schedule: { read: true, write: true, delete: true },
|
||||
tournaments: { read: true, write: true, delete: true },
|
||||
statistics: { read: true, write: true },
|
||||
settings: { read: true, write: true },
|
||||
permissions: { read: true, write: true },
|
||||
approvals: { read: true, write: true },
|
||||
mytischtennis_admin: { read: true, write: true },
|
||||
predefined_activities: { read: true, write: true, delete: true }
|
||||
},
|
||||
trainer: {
|
||||
diary: { read: true, write: true, delete: true },
|
||||
members: { read: true, write: true, delete: false },
|
||||
teams: { read: true, write: true, delete: false },
|
||||
schedule: { read: true, write: false, delete: false },
|
||||
tournaments: { read: true, write: true, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: true, write: true, delete: true }
|
||||
},
|
||||
team_manager: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: true, write: false, delete: false },
|
||||
teams: { read: true, write: true, delete: false },
|
||||
schedule: { read: true, write: true, delete: false },
|
||||
tournaments: { read: true, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false }
|
||||
},
|
||||
tournament_manager: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: true, write: false, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: true, write: true, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false }
|
||||
},
|
||||
member: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: false, write: false, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: false, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false }
|
||||
}
|
||||
};
|
||||
|
||||
return rolePermissions[role] || rolePermissions.member;
|
||||
};
|
||||
|
||||
const getActionLabel = (action) => {
|
||||
const labels = {
|
||||
read: 'Lesen',
|
||||
write: 'Schreiben',
|
||||
delete: 'Löschen'
|
||||
};
|
||||
return labels[action] || action;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!currentClub.value) {
|
||||
error.value = 'Bitte wählen Sie einen Club aus';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
members,
|
||||
availableRoles,
|
||||
permissionStructure,
|
||||
selectedMember,
|
||||
customPermissions,
|
||||
isReadOnly,
|
||||
isOwner,
|
||||
isAdmin,
|
||||
infoDialog,
|
||||
confirmDialog,
|
||||
showInfo,
|
||||
showConfirm,
|
||||
handleConfirmResult,
|
||||
updateMemberRole,
|
||||
toggleMemberStatus,
|
||||
openPermissionsDialog,
|
||||
closePermissionsDialog,
|
||||
saveCustomPermissions,
|
||||
togglePermission,
|
||||
resetResource,
|
||||
resetAll,
|
||||
stateLabel,
|
||||
stateClass,
|
||||
getRoleLabel,
|
||||
getActionLabel
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.permissions-view {
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #7f8c8d;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #fee;
|
||||
color: #c33;
|
||||
}
|
||||
|
||||
.role-legend {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.role-legend h3 {
|
||||
margin-top: 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.role-name {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.role-description {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.members-table {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.members-table h3 {
|
||||
margin-top: 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.role-select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.role-admin {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.role-trainer {
|
||||
background-color: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.role-team_manager {
|
||||
background-color: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.role-member {
|
||||
background-color: #f5f5f5;
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
.owner-badge {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.status-badge.clickable {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.status-badge.clickable:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Dialog Styles */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.dialog-header h2 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
background-color: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.permissions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.permission-group {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.permission-group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.permission-group h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.permission-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.permission-action-label {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.permission-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.permission-checkbox input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 15px 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
color: #333;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-reset:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.perm-state {
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.state-inherit {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.state-allow {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.state-deny {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,10 +6,20 @@
|
||||
<div class="toolbar">
|
||||
<button @click="startCreate" class="btn-primary">Neu</button>
|
||||
<button @click="reload" class="btn-secondary">Neu laden</button>
|
||||
<div>
|
||||
</div>
|
||||
<div class="search-section">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
@input="onSearchInput"
|
||||
placeholder="Kürzel suchen (z.B. 'as vh us' oder 'vh as us')..."
|
||||
class="search-input"
|
||||
/>
|
||||
<button v-if="searchQuery" @click="clearSearch" class="btn-clear-search">✕</button>
|
||||
</div>
|
||||
<div>
|
||||
<button @click="deduplicate" class="btn-secondary">Doppelungen zusammenführen</button>
|
||||
</div
|
||||
</div>
|
||||
<div class="merge-tools">
|
||||
<select v-model="mergeSourceId">
|
||||
<option disabled value="">Quelle wählen…</option>
|
||||
@@ -22,7 +32,6 @@
|
||||
</select>
|
||||
<button class="btn-secondary" :disabled="!canMerge" @click="mergeSelected">Zusammenführen</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="items">
|
||||
<li v-for="a in sortedActivities" :key="a.id" :class="{ active: selectedActivity && selectedActivity.id === a.id }" @click="select(a)">
|
||||
<div class="title">
|
||||
@@ -38,6 +47,11 @@
|
||||
|
||||
<div class="detail" v-if="editModel">
|
||||
<h3>{{ editModel.id ? 'Aktivität bearbeiten' : 'Neue Aktivität' }}</h3>
|
||||
<div class="drawing-button-section">
|
||||
<button type="button" class="btn-secondary" @click="showDrawingDialog = true">
|
||||
{{ editModel.drawingData ? 'Übungszeichnung bearbeiten' : 'Übungszeichnung erstellen' }}
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<label>Name
|
||||
<input type="text" v-model="editModel.name" required />
|
||||
@@ -56,7 +70,7 @@
|
||||
</label>
|
||||
<div class="image-section">
|
||||
<h4>Bild hinzufügen</h4>
|
||||
<p class="image-help">Du kannst entweder einen Link zu einem Bild eingeben, ein Bild hochladen oder eine Übungszeichnung erstellen:</p>
|
||||
<p class="image-help">Du kannst entweder einen Link zu einem Bild eingeben oder ein Bild hochladen:</p>
|
||||
|
||||
<label>Bild-Link (optional)
|
||||
<input type="text" v-model="editModel.imageLink" placeholder="z.B. https://example.com/bild.jpg oder /api/predefined-activities/:id/image/:imageId" />
|
||||
@@ -74,18 +88,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Zeichen-Tool -->
|
||||
<div class="drawing-section">
|
||||
<h5>Übungszeichnung erstellen</h5>
|
||||
<CourtDrawingTool
|
||||
:activity-id="editModel.id"
|
||||
:drawing-data="editModel.drawingData"
|
||||
:allow-image-upload="false"
|
||||
@update-fields="onUpdateFields"
|
||||
@update-drawing-data="onUpdateDrawingData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="image-list" v-if="images && images.length">
|
||||
<h5>Hochgeladene Bilder:</h5>
|
||||
<div class="image-grid">
|
||||
@@ -105,19 +107,69 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
|
||||
<!-- Court Drawing Dialog -->
|
||||
<CourtDrawingDialog
|
||||
v-model="showDrawingDialog"
|
||||
:initial-drawing-data="editModel ? editModel.drawingData : null"
|
||||
@ok="handleDrawingDialogOk"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
import CourtDrawingTool from '../components/CourtDrawingTool.vue';
|
||||
import CourtDrawingDialog from '../components/CourtDrawingDialog.vue';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { debounce } from '../utils/debounce.js';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage } from '../utils/dialogUtils.js';
|
||||
export default {
|
||||
name: 'PredefinedActivities',
|
||||
components: {
|
||||
CourtDrawingTool
|
||||
},
|
||||
CourtDrawingDialog,
|
||||
InfoDialog,
|
||||
ConfirmDialog},
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
infoDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
},
|
||||
confirmDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
},
|
||||
activities: [],
|
||||
selectedActivity: null,
|
||||
editModel: null,
|
||||
@@ -126,11 +178,18 @@ export default {
|
||||
selectedDrawingData: null,
|
||||
mergeSourceId: '',
|
||||
mergeTargetId: '',
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
showDrawingDialog: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
sortedActivities() {
|
||||
return [...(this.activities || [])].sort((a, b) => {
|
||||
// Wenn gesucht wird, zeige Suchergebnisse, sonst alle Aktivitäten
|
||||
const activitiesToSort = this.searchQuery.trim() ? this.searchResults : this.activities;
|
||||
|
||||
return [...(activitiesToSort || [])].sort((a, b) => {
|
||||
const ac = (a.code || '').toLocaleLowerCase('de-DE');
|
||||
const bc = (b.code || '').toLocaleLowerCase('de-DE');
|
||||
const aEmpty = ac === '';
|
||||
@@ -148,6 +207,60 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = buildInfoConfig({ title, message, details, type });
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info', options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = buildConfirmConfig({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
// Suchfunktionen
|
||||
async onSearchInput() {
|
||||
const query = this.searchQuery.trim();
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
this.searchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSearching = true;
|
||||
try {
|
||||
const response = await apiClient.get('/predefined-activities/search/query', {
|
||||
params: { q: query, limit: 50 }
|
||||
});
|
||||
this.searchResults = response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error searching activities:', error);
|
||||
this.searchResults = [];
|
||||
} finally {
|
||||
this.isSearching = false;
|
||||
}
|
||||
},
|
||||
|
||||
clearSearch() {
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
},
|
||||
|
||||
parseDrawingData(value) {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') return value;
|
||||
@@ -190,7 +303,13 @@ export default {
|
||||
async mergeSelected() {
|
||||
if (!this.canMerge) return;
|
||||
const src = this.mergeSourceId; const tgt = this.mergeTargetId;
|
||||
if (!confirm(`Eintrag #${src} in #${tgt} zusammenführen?\nAlle Verknüpfungen werden auf das Ziel umgebogen, die Quelle wird gelöscht.`)) return;
|
||||
const confirmed = await this.showConfirm(
|
||||
'Aktivitäten zusammenführen',
|
||||
`Eintrag #${src} in #${tgt} zusammenführen?`,
|
||||
'Alle Verknüpfungen werden auf das Ziel umgebogen, die Quelle wird gelöscht.',
|
||||
'warning'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
await apiClient.post('/predefined-activities/merge', { sourceId: src, targetId: tgt });
|
||||
this.mergeSourceId = '';
|
||||
this.mergeTargetId = '';
|
||||
@@ -217,6 +336,25 @@ export default {
|
||||
async save() {
|
||||
if (!this.editModel) return;
|
||||
|
||||
// Prüfe auf Duplikate nur bei neuen Aktivitäten (nicht bei Updates)
|
||||
if (!this.editModel.id && this.editModel.code && this.editModel.code.trim()) {
|
||||
const codeToCheck = this.editModel.code.trim().toLowerCase();
|
||||
const existing = this.activities.find(a =>
|
||||
a.code && a.code.trim().toLowerCase() === codeToCheck
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const confirmed = await this.showConfirm(
|
||||
'Aktivität existiert bereits',
|
||||
`Eine Aktivität mit dem Kürzel "${this.editModel.code}" existiert bereits:\n${this.formatItem(existing)}\n\nTrotzdem speichern?`,
|
||||
'',
|
||||
'warning'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return; // Speichern abbrechen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.editModel.id) {
|
||||
const { id, ...payload } = this.editModel;
|
||||
@@ -238,11 +376,29 @@ export default {
|
||||
|
||||
await this.reload();
|
||||
},
|
||||
onFileChange(e) {
|
||||
this.selectedFile = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
||||
async onFileChange(e) {
|
||||
const file = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
||||
if (!file) {
|
||||
this.selectedFile = null;
|
||||
return;
|
||||
}
|
||||
const maxSize = 5 * 1024 * 1024; // 5 MB
|
||||
if (!file.type.startsWith('image/')) {
|
||||
await this.showInfo('Ungültige Datei', 'Bitte wählen Sie eine Bilddatei aus.', '', 'warning');
|
||||
this.selectedFile = null;
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
if (file.size > maxSize) {
|
||||
await this.showInfo('Datei zu groß', 'Das Bild darf maximal 5 MB groß sein.', '', 'warning');
|
||||
this.selectedFile = null;
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
this.selectedFile = file;
|
||||
},
|
||||
imageUrl(img) {
|
||||
return `http://localhost:3000/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
|
||||
return `/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
|
||||
},
|
||||
async uploadImage() {
|
||||
if (!this.editModel || !this.editModel.id || !this.selectedFile) {
|
||||
@@ -264,7 +420,7 @@ export default {
|
||||
|
||||
|
||||
try {
|
||||
const response = await apiClient[method](`/predefined-activities/${this.editModel.id}/image`, fd, {
|
||||
await apiClient[method](`/predefined-activities/${this.editModel.id}/image`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
@@ -276,23 +432,27 @@ export default {
|
||||
await this.reloadImages();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
const message = safeErrorMessage(error, 'Bild-Upload fehlgeschlagen.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async deleteImage(imageId) {
|
||||
if (!this.editModel || !this.editModel.id) return;
|
||||
if (!confirm('Bild wirklich löschen?')) return;
|
||||
const confirmed = await this.showConfirm('Bestätigung', 'Bild wirklich löschen?', '', 'warning');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await apiClient.delete(`/predefined-activities/${this.editModel.id}/image/${imageId}`);
|
||||
// Nach Löschen Details neu laden
|
||||
await this.select(this.editModel);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Bildes:', error);
|
||||
alert('Fehler beim Löschen des Bildes');
|
||||
this.showInfo('Fehler', 'Fehler beim Löschen des Bildes', '', 'error');
|
||||
}
|
||||
},
|
||||
async deduplicate() {
|
||||
if (!confirm('Alle Aktivitäten mit identischem Namen werden zusammengeführt. Fortfahren?')) return;
|
||||
const confirmed = await this.showConfirm('Bestätigung', 'Alle Aktivitäten mit identischem Namen werden zusammengeführt. Fortfahren?', '', 'warning');
|
||||
if (!confirmed) return;
|
||||
await apiClient.post('/predefined-activities/deduplicate', {});
|
||||
await this.reload();
|
||||
},
|
||||
@@ -306,16 +466,22 @@ export default {
|
||||
}
|
||||
// Nicht automatisch speichern, nur wenn User explizit "Speichern" klickt
|
||||
},
|
||||
onUpdateFields(fields) {
|
||||
if (this.editModel) {
|
||||
this.editModel.code = fields.code;
|
||||
this.editModel.name = fields.name;
|
||||
this.editModel.description = fields.description;
|
||||
}
|
||||
},
|
||||
onUpdateDrawingData(data) {
|
||||
if (this.editModel) {
|
||||
this.editModel.drawingData = data;
|
||||
handleDrawingDialogOk(result) {
|
||||
if (this.editModel && result) {
|
||||
// Übernehme alle Daten vom Dialog
|
||||
if (result.drawingData) {
|
||||
this.editModel.drawingData = result.drawingData;
|
||||
}
|
||||
if (result.fields) {
|
||||
this.editModel.code = result.fields.code || result.code || '';
|
||||
this.editModel.name = result.fields.name || result.name || '';
|
||||
this.editModel.description = result.fields.description || result.description || '';
|
||||
} else {
|
||||
// Fallback falls fields nicht vorhanden
|
||||
if (result.code) this.editModel.code = result.code;
|
||||
if (result.name) this.editModel.name = result.name;
|
||||
if (result.description) this.editModel.description = result.description;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -468,18 +634,55 @@ input[type="text"], input[type="number"], textarea { width: 100%; }
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.drawing-section {
|
||||
.drawing-button-section {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.drawing-section h5 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #333;
|
||||
/* Suchfeld Styles */
|
||||
.search-section {
|
||||
position: relative;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 2.5rem 0.75rem 1rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 1rem;
|
||||
background: white;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-clear-search {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 50%;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-clear-search:hover {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -11,25 +11,92 @@
|
||||
<p>Bereits ein Konto? <router-link to="/login">Zum Login</router-link></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage } from '../utils/dialogUtils.js';
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
infoDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
},
|
||||
confirmDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
},
|
||||
email: '',
|
||||
password: '',
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = buildInfoConfig({ title, message, details, type });
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info', options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = buildConfirmConfig({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
async register() {
|
||||
try {
|
||||
await axios.post(`${import.meta.env.VITE_BACKEND}/api/auth/register`, { email: this.email, password: this.password });
|
||||
alert('Registration successful! Please check your email to activate your account.');
|
||||
await axios.post(`${import.meta.env.VITE_BACKEND || 'http://localhost:3005'}/api/auth/register`, { email: this.email, password: this.password });
|
||||
await this.showInfo('Erfolg', 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mails, um den Account zu aktivieren.', '', 'success');
|
||||
} catch (error) {
|
||||
alert('Registrierung fehlgeschlagen');
|
||||
const message = safeErrorMessage(error, 'Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -43,7 +43,28 @@
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
<button @click="removeParticipant(participant)" style="margin-left:0.5rem" class="trash-btn">
|
||||
🗑️
|
||||
</button>
|
||||
@@ -112,7 +133,7 @@
|
||||
</td>
|
||||
<td v-for="(opponent, oppIdx) in groupRankings[group.groupId]"
|
||||
:key="`match-${pl.id}-${opponent.id}`"
|
||||
:class="['match-cell', { 'clickable': idx !== oppIdx }]"
|
||||
:class="['match-cell', { 'clickable': idx !== oppIdx, 'active-group-cell': activeGroupCells.includes(`match-${pl.id}-${opponent.id}`), 'diagonal-cell': 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)"
|
||||
@@ -150,7 +171,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in groupMatches" :key="m.id" :data-match-id="m.id">
|
||||
<tr v-for="m in groupMatches" :key="m.id" :data-match-id="m.id" :class="{ 'active-match': activeMatchId === m.id }" @click="activeMatchId = m.id">
|
||||
<td>{{ m.groupRound }}</td>
|
||||
<td>{{ m.groupNumber }}</td>
|
||||
<td>
|
||||
@@ -161,22 +182,139 @@
|
||||
<span v-else>
|
||||
{{ getPlayerName(m.player1) }} – <strong>{{ getPlayerName(m.player2) }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getPlayerName(m.player1) }} – {{ getPlayerName(m.player2) }}
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<!-- 1. Fall: Match ist noch offen → Edit‑Mode -->
|
||||
<template v-if="!m.isFinished">
|
||||
<!-- existierende Sätze als klickbare Labels -->
|
||||
<!-- existierende Sätze als klickbare Labels oder editierbare Eingabefelder -->
|
||||
<template v-for="r in m.tournamentResults" :key="r.set">
|
||||
<span @click="startEditResult(m, r)" class="result-text">
|
||||
{{ r.pointsPlayer1 }}:{{ r.pointsPlayer2 }}
|
||||
</span>
|
||||
<span v-if="!isLastResult(m, r)">, </span>
|
||||
</template>
|
||||
<template v-if="isEditing(m, r.set)">
|
||||
<input
|
||||
v-model="editingResult.value"
|
||||
@keyup.enter="saveEditedResult(m)"
|
||||
@blur="saveEditedResult(m)"
|
||||
@keyup.escape="cancelEdit"
|
||||
class="inline-input"
|
||||
ref="editInput"
|
||||
/>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span @click="startEditResult(m, r)" class="result-text clickable">
|
||||
{{ r.pointsPlayer1 }}:{{ r.pointsPlayer2 }}
|
||||
</span>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="!isLastResult(m, r)">, </span>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Eingabefeld für neue Sätze (immer sichtbar solange offen) -->
|
||||
<div class="new-set-line">
|
||||
@@ -184,12 +322,54 @@
|
||||
@keyup.enter="saveMatchResult(m, m.resultInput)"
|
||||
@blur="saveMatchResult(m, m.resultInput)" class="inline-input" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 2. Fall: Match ist abgeschlossen → Read‑only -->
|
||||
<template v-else>
|
||||
{{ formatResult(m) }}
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
{{ getSetsString(m) }}
|
||||
@@ -235,7 +415,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in knockoutMatches" :key="m.id">
|
||||
<tr v-for="m in knockoutMatches" :key="m.id" :class="{ 'active-match': activeMatchId === m.id }" @click="activeMatchId = m.id">
|
||||
<td>{{ m.round }}</td>
|
||||
<td>
|
||||
<template v-if="m.isFinished">
|
||||
@@ -245,21 +425,138 @@
|
||||
<span v-else>
|
||||
{{ getPlayerName(m.player1) }} – <strong>{{ getPlayerName(m.player2) }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getPlayerName(m.player1) }} – {{ getPlayerName(m.player2) }}
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<!-- 1. Fall: Match ist noch offen → Edit‑Mode -->
|
||||
<template v-if="!m.isFinished">
|
||||
<!-- existierende Sätze als klickbare Labels -->
|
||||
<!-- existierende Sätze als klickbare Labels oder editierbare Eingabefelder -->
|
||||
<template v-for="r in m.tournamentResults" :key="r.set">
|
||||
<span @click="startEditResult(m, r)" class="result-text">
|
||||
{{ r.pointsPlayer1 }}:{{ r.pointsPlayer2 }}
|
||||
</span>
|
||||
<span v-if="!isLastResult(m, r)">, </span>
|
||||
</template>
|
||||
<template v-if="isEditing(m, r.set)">
|
||||
<input
|
||||
v-model="editingResult.value"
|
||||
@keyup.enter="saveEditedResult(m)"
|
||||
@blur="saveEditedResult(m)"
|
||||
@keyup.escape="cancelEdit"
|
||||
class="inline-input"
|
||||
ref="editInput"
|
||||
/>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span @click="startEditResult(m, r)" class="result-text clickable">
|
||||
{{ r.pointsPlayer1 }}:{{ r.pointsPlayer2 }}
|
||||
</span>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="!isLastResult(m, r)">, </span>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Eingabefeld für neue Sätze (immer sichtbar solange offen) -->
|
||||
<div class="new-set-line">
|
||||
@@ -267,12 +564,54 @@
|
||||
@keyup.enter="saveMatchResult(m, m.resultInput)"
|
||||
@blur="saveMatchResult(m, m.resultInput)" class="inline-input" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 2. Fall: Match ist abgeschlossen → Read‑only -->
|
||||
<template v-else>
|
||||
{{ formatResult(m) }}
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
{{ getSetsString(m) }}
|
||||
@@ -306,16 +645,55 @@
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import apiClient from '../apiClient';
|
||||
|
||||
import apiClient from '../apiClient.js';
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage } from '../utils/dialogUtils.js';
|
||||
export default {
|
||||
name: 'TournamentsView',
|
||||
data() {
|
||||
return {
|
||||
// Dialog States
|
||||
infoDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
},
|
||||
confirmDialog: {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
},
|
||||
selectedDate: 'new',
|
||||
newDate: '',
|
||||
dates: [],
|
||||
@@ -335,7 +713,8 @@ export default {
|
||||
matchId: null, // aktuell bearbeitetes Match
|
||||
set: null, // aktuell bearbeitete Satz‑Nummer
|
||||
value: '' // Eingabewert
|
||||
}
|
||||
},
|
||||
activeMatchId: null // Angeklicktes/aktives Match (für Hervorhebung)
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -345,6 +724,19 @@ export default {
|
||||
return this.matches.filter(m => m.round !== 'group');
|
||||
},
|
||||
|
||||
// Computed property für aktive Gruppentabellen-Zellen
|
||||
activeGroupCells() {
|
||||
if (!this.activeMatchId) return [];
|
||||
|
||||
const match = this.matches.find(m => m.id === this.activeMatchId);
|
||||
if (!match || match.round !== 'group') return [];
|
||||
|
||||
return [
|
||||
`match-${match.player1.id}-${match.player2.id}`,
|
||||
`match-${match.player2.id}-${match.player1.id}`
|
||||
];
|
||||
},
|
||||
|
||||
groupMatches() {
|
||||
return this.matches
|
||||
.filter(m => m.round === 'group')
|
||||
@@ -397,9 +789,23 @@ export default {
|
||||
if (b.setsWon !== a.setsWon) return b.setsWon - a.setsWon;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
rankings[gid] = arr.map((p, i) => ({
|
||||
...p, position: i + 1
|
||||
}));
|
||||
// Weise Positionen zu, wobei Spieler mit identischen Werten den gleichen Platz bekommen
|
||||
let currentPosition = 1;
|
||||
rankings[gid] = arr.map((p, i) => {
|
||||
// Wenn nicht der erste Spieler und die Werte identisch sind, verwende die gleiche Position
|
||||
if (i > 0) {
|
||||
const prev = arr[i - 1];
|
||||
if (prev.points === p.points &&
|
||||
prev.setDiff === p.setDiff &&
|
||||
prev.setsWon === p.setsWon) {
|
||||
// Gleicher Platz wie Vorgänger
|
||||
return { ...p, position: currentPosition };
|
||||
}
|
||||
}
|
||||
// Neuer Platz
|
||||
currentPosition = i + 1;
|
||||
return { ...p, position: currentPosition };
|
||||
});
|
||||
});
|
||||
return rankings;
|
||||
},
|
||||
@@ -497,6 +903,32 @@ export default {
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = buildInfoConfig({ title, message, details, type });
|
||||
},
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info', options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = buildConfirmConfig({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
normalizeResultInput(raw) {
|
||||
const s = raw.trim();
|
||||
if (s.includes(':')) {
|
||||
@@ -520,10 +952,11 @@ export default {
|
||||
const losing = Math.abs(num);
|
||||
const winning = losing < 10 ? 11 : losing + 2;
|
||||
|
||||
if (num >= 0) {
|
||||
return `${winning}:${losing}`;
|
||||
} else {
|
||||
// Prüfe das ursprüngliche Vorzeichen vor der Konvertierung
|
||||
if (s.startsWith('-')) {
|
||||
return `${losing}:${winning}`;
|
||||
} else {
|
||||
return `${winning}:${losing}`;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -635,7 +1068,8 @@ export default {
|
||||
this.newDate = '';
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen des Turniers:', error);
|
||||
alert('Fehler beim Erstellen des Turniers: ' + (error.response?.data?.error || error.message));
|
||||
const message = safeErrorMessage(error, 'Fehler beim Erstellen des Turniers.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -690,8 +1124,8 @@ export default {
|
||||
});
|
||||
this.participants = r.data;
|
||||
} catch (err) {
|
||||
alert('Fehler beim Zufällig‑Verteilen:\n' +
|
||||
(err.response?.data?.error || err.message));
|
||||
const message = safeErrorMessage(err, 'Fehler beim Zufällig-Verteilen.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
await this.loadTournamentData();
|
||||
},
|
||||
@@ -715,7 +1149,8 @@ export default {
|
||||
);
|
||||
const updated = allRes.data.find(m2 => m2.id === match.id);
|
||||
if (!updated) {
|
||||
alert('Fehler beim Aktualisieren des Matches');
|
||||
const message = safeErrorMessage(error, 'Fehler beim Aktualisieren des Matches.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
return;
|
||||
}
|
||||
match.tournamentResults = updated.tournamentResults || [];
|
||||
@@ -849,12 +1284,26 @@ export default {
|
||||
this.editingResult.matchId = match.id;
|
||||
this.editingResult.set = result.set;
|
||||
this.editingResult.value = `${result.pointsPlayer1}:${result.pointsPlayer2}`;
|
||||
this.activeMatchId = match.id; // Setze aktives Match für Hervorhebung
|
||||
|
||||
// Fokussiere das Eingabefeld nach dem nächsten DOM-Update
|
||||
this.$nextTick(() => {
|
||||
const editInputs = this.$refs.editInput;
|
||||
if (editInputs && editInputs.length > 0) {
|
||||
const lastInput = editInputs[editInputs.length - 1];
|
||||
lastInput.focus();
|
||||
lastInput.select();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async saveEditedResult(match) {
|
||||
const { set, value } = this.editingResult;
|
||||
const normalized = this.normalizeResultInput(value);
|
||||
if (!normalized) return;
|
||||
if (!normalized) {
|
||||
this.cancelEdit();
|
||||
return;
|
||||
}
|
||||
let result = normalized;
|
||||
await apiClient.post('/tournament/match/result', {
|
||||
clubId: this.currentClub,
|
||||
@@ -869,6 +1318,12 @@ export default {
|
||||
await this.loadTournamentData();
|
||||
},
|
||||
|
||||
cancelEdit() {
|
||||
this.editingResult.matchId = null;
|
||||
this.editingResult.set = null;
|
||||
this.editingResult.value = '';
|
||||
},
|
||||
|
||||
isEditing(match, set) {
|
||||
return (
|
||||
this.editingResult.matchId === match.id &&
|
||||
@@ -906,7 +1361,8 @@ export default {
|
||||
});
|
||||
await this.loadTournamentData();
|
||||
} catch (err) {
|
||||
alert('Fehler beim Zurücksetzen der K.o.-Runde');
|
||||
const message = safeErrorMessage(err, 'Fehler beim Zurücksetzen der K.o.-Runde.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -992,6 +1448,9 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Setze activeMatchId für bidirektionale Hervorhebung
|
||||
this.activeMatchId = match.id;
|
||||
|
||||
// Setze Highlight-Klasse
|
||||
this.$nextTick(() => {
|
||||
const matchElement = document.querySelector(`tr[data-match-id="${match.id}"]`);
|
||||
@@ -1118,20 +1577,21 @@ export default {
|
||||
// Lade Turnierdaten neu
|
||||
await this.loadTournamentData();
|
||||
} else {
|
||||
alert('Keine gültigen Teilnehmer im heutigen Trainingstag gefunden!');
|
||||
await this.showInfo('Hinweis', 'Keine gültigen Teilnehmer im heutigen Trainingstag gefunden!', '', 'info');
|
||||
}
|
||||
} else {
|
||||
alert('Keine Teilnehmer im heutigen Trainingstag gefunden!');
|
||||
await this.showInfo('Hinweis', 'Keine Teilnehmer im heutigen Trainingstag gefunden!', '', 'info');
|
||||
}
|
||||
} else {
|
||||
alert('Kein Trainingstag für heute gefunden!');
|
||||
await this.showInfo('Hinweis', 'Kein Trainingstag für heute gefunden!', '', 'info');
|
||||
}
|
||||
} else {
|
||||
alert('Kein Trainingstag für heute gefunden!');
|
||||
await this.showInfo('Hinweis', 'Kein Trainingstag für heute gefunden!', '', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Trainingsteilnehmer:', error);
|
||||
alert('Fehler beim Laden der Trainingsteilnehmer: ' + (error.response?.data?.error || error.message));
|
||||
const message = safeErrorMessage(error, 'Fehler beim Laden der Trainingsteilnehmer.');
|
||||
await this.showInfo('Fehler', message, '', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1254,9 +1714,27 @@ export default {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Weise Positionen zu, wobei Spieler mit identischen Werten den gleichen Platz bekommen
|
||||
let currentPosition = 1;
|
||||
const positions = liveStats.map((p, i) => {
|
||||
// Wenn nicht der erste Spieler und die Werte identisch sind, verwende die gleiche Position
|
||||
if (i > 0) {
|
||||
const prev = liveStats[i - 1];
|
||||
if (prev.livePoints === p.livePoints &&
|
||||
prev.liveSetDiff === p.liveSetDiff &&
|
||||
prev.liveSetsWon === p.liveSetsWon) {
|
||||
// Gleicher Platz wie Vorgänger
|
||||
return currentPosition;
|
||||
}
|
||||
}
|
||||
// Neuer Platz
|
||||
currentPosition = i + 1;
|
||||
return currentPosition;
|
||||
});
|
||||
|
||||
// Finde Position des Spielers
|
||||
const position = liveStats.findIndex(p => p.id === playerId) + 1;
|
||||
return position;
|
||||
const playerIndex = liveStats.findIndex(p => p.id === playerId);
|
||||
return playerIndex >= 0 ? positions[playerIndex] : liveStats.length + 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1295,13 +1773,22 @@ button {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.match-cell.diagonal-cell {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.diagonal {
|
||||
background-color: #000;
|
||||
color: #000;
|
||||
background-color: #bbb;
|
||||
color: #bbb;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.match-result {
|
||||
@@ -1483,4 +1970,67 @@ button {
|
||||
50% { transform: scale(1.02); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Container für neue Satzeingabe - inline */
|
||||
.new-set-line {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Eingabefeld für Sätze - nur so breit wie nötig für die Eingabe */
|
||||
.inline-input {
|
||||
width: 5ch; /* 5 Zeichen breit */
|
||||
padding: calc(0.2rem + 2px) calc(0.4rem + 2px);
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.5;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Klickbare Ergebnis-Texte */
|
||||
.result-text.clickable {
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1.5;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.result-text.clickable:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
/* Aktive Begegnung hervorheben */
|
||||
tr.active-match {
|
||||
background-color: #fff3cd !important;
|
||||
border-left: 3px solid #ffc107;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.active-match:hover {
|
||||
background-color: #ffe69c !important;
|
||||
}
|
||||
|
||||
/* Tabellenzeilen klickbar machen */
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
tbody tr:hover:not(.active-match) {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Aktive Gruppentabellen-Zellen hervorheben */
|
||||
.match-cell.active-group-cell {
|
||||
background-color: #fff3cd !important;
|
||||
border: 2px solid #ffc107 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -9,12 +9,24 @@
|
||||
<div class="stat-number">{{ activeMembers.length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Durchschnittliche Teilnahme (12 Monate)</h3>
|
||||
<div class="stat-number">{{ averageParticipation12Months.toFixed(1) }}</div>
|
||||
<h3>Durchschnittliche Teilnahme (aktueller Monat)</h3>
|
||||
<div class="stat-number">{{ averageParticipationCurrentMonth.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Durchschnittliche Teilnahme (3 Monate)</h3>
|
||||
<div class="stat-number">{{ averageParticipation3Months.toFixed(1) }}</div>
|
||||
<h3>Durchschnittliche Teilnahme (letzter Monat)</h3>
|
||||
<div class="stat-number">{{ averageParticipationLastMonth.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Durchschnittliche Teilnahme (Quartal)</h3>
|
||||
<div class="stat-number">{{ averageParticipationQuarter.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Durchschnittliche Teilnahme (Halbjahr)</h3>
|
||||
<div class="stat-number">{{ averageParticipationHalfYear.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Durchschnittliche Teilnahme (Jahr)</h3>
|
||||
<div class="stat-number">{{ averageParticipationYear.toFixed(1) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,6 +76,8 @@
|
||||
<span class="sort-icon">{{ getSortIcon('name') }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>TTR</th>
|
||||
<th>QTTR</th>
|
||||
<th>Geburtsdatum</th>
|
||||
<th @click="sortBy('participation12Months')" class="sortable-header">
|
||||
<div class="header-content">
|
||||
@@ -95,6 +109,8 @@
|
||||
<tbody>
|
||||
<tr v-for="member in sortedMembers" :key="member.id" class="member-row">
|
||||
<td>{{ member.firstName }} {{ member.lastName }}</td>
|
||||
<td>{{ member.ttr ?? '–' }}</td>
|
||||
<td>{{ member.qttr ?? '–' }}</td>
|
||||
<td>{{ formatBirthdate(member.birthDate) }}</td>
|
||||
<td>{{ member.participation12Months }}</td>
|
||||
<td>{{ member.participation3Months }}</td>
|
||||
@@ -113,52 +129,25 @@
|
||||
</div>
|
||||
|
||||
<!-- Modal für Mitgliedsdetails -->
|
||||
<div v-if="showDetailsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" @click="closeDetailsModal">×</span>
|
||||
<h3>Trainings-Details: {{ selectedMember.firstName }} {{ selectedMember.lastName }}</h3>
|
||||
|
||||
<div class="member-info">
|
||||
<p><strong>Geburtsdatum:</strong> {{ formatBirthdate(selectedMember.birthDate) }}</p>
|
||||
<p><strong>Geburtsjahr:</strong> {{ getBirthYear(selectedMember.birthDate) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="participation-summary">
|
||||
<div class="summary-item">
|
||||
<span class="label">Letzte 12 Monate:</span>
|
||||
<span class="value">{{ selectedMember.participation12Months }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Letzte 3 Monate:</span>
|
||||
<span class="value">{{ selectedMember.participation3Months }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Gesamt:</span>
|
||||
<span class="value">{{ selectedMember.participationTotal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="training-details">
|
||||
<h4>Trainingsteilnahmen (absteigend sortiert)</h4>
|
||||
<div class="training-list">
|
||||
<div v-for="training in selectedMember.trainingDetails" :key="training.id" class="training-item">
|
||||
<div class="training-date">{{ formatDate(training.date) }}</div>
|
||||
<div class="training-activity">{{ training.activityName }}</div>
|
||||
<div class="training-time">{{ training.startTime }} - {{ training.endTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Training Details Modal -->
|
||||
<TrainingDetailsDialog
|
||||
v-model="showDetailsModal"
|
||||
:member="selectedMember"
|
||||
@close="closeDetailsModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import apiClient from '../apiClient';
|
||||
import TrainingDetailsDialog from '../components/TrainingDetailsDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'TrainingStatsView',
|
||||
components: {
|
||||
TrainingDetailsDialog
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isAuthenticated', 'currentClub']),
|
||||
|
||||
@@ -173,6 +162,74 @@ export default {
|
||||
const total = this.activeMembers.reduce((sum, member) => sum + member.participation3Months, 0);
|
||||
return total / this.trainingsCount3Months;
|
||||
},
|
||||
|
||||
// Neue Zeiträume basierend auf verfügbaren Daten
|
||||
averageParticipationCurrentMonth() {
|
||||
const currentMonth = new Date().getMonth();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const trainingsThisMonth = this.getTrainingsInPeriod(currentYear, currentMonth, currentYear, currentMonth);
|
||||
if (trainingsThisMonth === 0) return 0;
|
||||
const totalParticipants = this.getTotalParticipantsInPeriod(currentYear, currentMonth, currentYear, currentMonth);
|
||||
return totalParticipants / trainingsThisMonth;
|
||||
},
|
||||
|
||||
averageParticipationLastMonth() {
|
||||
const lastMonth = new Date().getMonth() - 1;
|
||||
const year = lastMonth < 0 ? new Date().getFullYear() - 1 : new Date().getFullYear();
|
||||
const actualMonth = lastMonth < 0 ? 11 : lastMonth;
|
||||
const trainingsLastMonth = this.getTrainingsInPeriod(year, actualMonth, year, actualMonth);
|
||||
if (trainingsLastMonth === 0) return 0;
|
||||
const totalParticipants = this.getTotalParticipantsInPeriod(year, actualMonth, year, actualMonth);
|
||||
return totalParticipants / trainingsLastMonth;
|
||||
},
|
||||
|
||||
averageParticipationQuarter() {
|
||||
// Finde das Quartal mit den meisten Trainingsdaten
|
||||
const quarters = [
|
||||
{ year: 2024, startMonth: 0, endMonth: 2, name: 'Q1 2024' },
|
||||
{ year: 2024, startMonth: 3, endMonth: 5, name: 'Q2 2024' },
|
||||
{ year: 2024, startMonth: 6, endMonth: 8, name: 'Q3 2024' },
|
||||
{ year: 2024, startMonth: 9, endMonth: 11, name: 'Q4 2024' },
|
||||
{ year: 2025, startMonth: 0, endMonth: 2, name: 'Q1 2025' },
|
||||
{ year: 2025, startMonth: 3, endMonth: 5, name: 'Q2 2025' },
|
||||
{ year: 2025, startMonth: 6, endMonth: 8, name: 'Q3 2025' },
|
||||
{ year: 2025, startMonth: 9, endMonth: 11, name: 'Q4 2025' }
|
||||
];
|
||||
|
||||
let bestQuarter = null;
|
||||
let maxTrainings = 0;
|
||||
|
||||
for (const quarter of quarters) {
|
||||
const trainings = this.getTrainingsInPeriod(quarter.year, quarter.startMonth, quarter.year, quarter.endMonth);
|
||||
if (trainings > maxTrainings) {
|
||||
maxTrainings = trainings;
|
||||
bestQuarter = quarter;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestQuarter || maxTrainings === 0) return 0;
|
||||
|
||||
const totalParticipants = this.getTotalParticipantsInPeriod(bestQuarter.year, bestQuarter.startMonth, bestQuarter.year, bestQuarter.endMonth);
|
||||
return totalParticipants / maxTrainings;
|
||||
},
|
||||
|
||||
averageParticipationHalfYear() {
|
||||
const now = new Date();
|
||||
const halfYearStartMonth = now.getMonth() < 6 ? 0 : 6;
|
||||
const halfYearEndMonth = now.getMonth() < 6 ? 5 : 11;
|
||||
const trainingsHalfYear = this.getTrainingsInPeriod(now.getFullYear(), halfYearStartMonth, now.getFullYear(), halfYearEndMonth);
|
||||
if (trainingsHalfYear === 0) return 0;
|
||||
const totalParticipants = this.getTotalParticipantsInPeriod(now.getFullYear(), halfYearStartMonth, now.getFullYear(), halfYearEndMonth);
|
||||
return totalParticipants / trainingsHalfYear;
|
||||
},
|
||||
|
||||
averageParticipationYear() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const trainingsYear = this.getTrainingsInPeriod(currentYear, 0, currentYear, 11);
|
||||
if (trainingsYear === 0) return 0;
|
||||
const totalParticipants = this.getTotalParticipantsInPeriod(currentYear, 0, currentYear, 11);
|
||||
return totalParticipants / trainingsYear;
|
||||
},
|
||||
|
||||
sortedMembers() {
|
||||
if (!this.activeMembers.length) return [];
|
||||
@@ -251,6 +308,36 @@ export default {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Hilfsmethoden für neue Zeiträume
|
||||
getTrainingsInPeriod(startYear, startMonth, endYear, endMonth) {
|
||||
return this.trainingDays.filter(day => {
|
||||
const dayDate = new Date(day.date);
|
||||
const dayYear = dayDate.getFullYear();
|
||||
const dayMonth = dayDate.getMonth();
|
||||
|
||||
if (dayYear < startYear || dayYear > endYear) return false;
|
||||
if (dayYear === startYear && dayMonth < startMonth) return false;
|
||||
if (dayYear === endYear && dayMonth > endMonth) return false;
|
||||
|
||||
return true;
|
||||
}).length;
|
||||
},
|
||||
|
||||
getTotalParticipantsInPeriod(startYear, startMonth, endYear, endMonth) {
|
||||
// Summiere die Teilnehmerzahlen aller Trainingstage im Zeitraum
|
||||
return this.trainingDays.filter(day => {
|
||||
const dayDate = new Date(day.date);
|
||||
const dayYear = dayDate.getFullYear();
|
||||
const dayMonth = dayDate.getMonth();
|
||||
|
||||
if (dayYear < startYear || dayYear > endYear) return false;
|
||||
if (dayYear === startYear && dayMonth < startMonth) return false;
|
||||
if (dayYear === endYear && dayMonth > endMonth) return false;
|
||||
|
||||
return true;
|
||||
}).reduce((sum, day) => sum + (day.participantCount || 0), 0);
|
||||
},
|
||||
|
||||
toggleTrainingDays() {
|
||||
this.showTrainingDays = !this.showTrainingDays;
|
||||
@@ -327,14 +414,14 @@ export default {
|
||||
|
||||
.stats-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: var(--border-radius-large);
|
||||
box-shadow: var(--shadow-light);
|
||||
text-align: center;
|
||||
@@ -342,15 +429,15 @@ export default {
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||