Enhance SeasonSelector and TeamManagementView with dialog components for improved user interaction. Introduce new dialog states and helper methods for consistent handling of information and confirmations. Update styles in TrainingStatsView to reflect new participation metrics and improve layout. Refactor document display in TeamManagementView to a table format for better readability.

This commit is contained in:
Torsten Schulz (local)
2025-10-08 14:43:53 +02:00
parent 40dcd0e54c
commit 946e4fce1e
5 changed files with 761 additions and 109 deletions

View File

@@ -0,0 +1,348 @@
# Dialog-Komponenten Übersicht
## 📋 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
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">&times;</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.

View File

@@ -68,10 +68,14 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useStore } from 'vuex';
import apiClient from '../apiClient.js';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import InfoDialog from './InfoDialog.vue';
import ConfirmDialog from './ConfirmDialog.vue';
export default {
name: 'SeasonSelector',
components: {
InfoDialog,
ConfirmDialog
},
props: {
modelValue: {
type: [String, Number],
@@ -86,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);
@@ -147,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 {
this.showInfo('Fehler', 'Fehler beim Erstellen der Saison', '', 'error');
showInfo('Fehler', 'Fehler beim Erstellen der Saison', '', 'error');
}
}
};
@@ -162,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;
@@ -173,6 +226,11 @@ export default {
});
return {
infoDialog,
confirmDialog,
showInfo,
showConfirm,
handleConfirmResult,
seasons,
selectedSeasonId,
showNewSeasonForm,

View File

@@ -85,25 +85,37 @@
<!-- PDF-Parsing Bereich für bereits hochgeladene Dokumente -->
<div v-if="teamDocuments.length > 0" class="pdf-parsing-section">
<h4>Bereits hochgeladene PDF-Dokumente parsen</h4>
<div class="document-list">
<div v-for="document in teamDocuments" :key="document.id" class="document-item">
<div class="document-info">
<span class="document-name">{{ document.originalFileName }}</span>
<span class="document-type">{{ document.documentType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }}</span>
<span class="document-size">{{ formatFileSize(document.fileSize) }}</span>
</div>
<div class="document-actions">
<button
@click="parsePDF(document)"
:disabled="document.mimeType !== 'application/pdf'"
class="parse-btn"
:title="document.mimeType !== 'application/pdf' ? 'Nur PDF-Dateien können geparst werden' : 'PDF parsen und Matches extrahieren'"
>
🔍 PDF parsen
</button>
</div>
</div>
</div>
<table class="document-table">
<thead>
<tr>
<th>Dateiname</th>
<th>Typ</th>
<th>Größe</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<tr v-for="document in teamDocuments" :key="document.id" class="document-row">
<td class="document-name">{{ document.originalFileName }}</td>
<td class="document-type">
<span class="type-badge" :class="document.documentType">
{{ document.documentType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }}
</span>
</td>
<td class="document-size">{{ formatFileSize(document.fileSize) }}</td>
<td class="document-actions">
<button
@click="parsePDF(document)"
:disabled="document.mimeType !== 'application/pdf'"
class="parse-btn"
:title="document.mimeType !== 'application/pdf' ? 'Nur PDF-Dateien können geparst werden' : 'PDF parsen und Matches extrahieren'"
>
🔍 Parsen
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -240,6 +252,23 @@ export default {
setup() {
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 teams = ref([]);
const leagues = ref([]);
@@ -634,7 +663,44 @@ export default {
}
};
// 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;
};
return {
infoDialog,
confirmDialog,
showInfo,
showConfirm,
handleConfirmResult,
teams,
leagues,
teamFormIsOpen,
@@ -679,7 +745,7 @@ export default {
<style scoped>
.newteam {
margin-bottom: 2rem;
margin-bottom: 1rem;
}
.toggle-new-team {
@@ -712,49 +778,53 @@ export default {
.new-team-form {
background: var(--background-light);
padding: 1.5rem;
padding: 0.75rem;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
margin-bottom: 1rem;
margin-bottom: 0.5rem;
}
.new-team-form label {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.new-team-form label span {
font-weight: 600;
color: var(--text-color);
min-width: 120px;
flex-shrink: 0;
}
.new-team-form input,
.new-team-form select {
padding: 0.75rem;
flex: 1;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
font-size: 1rem;
font-size: 0.9rem;
}
.new-team-form input:focus,
.new-team-form select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-light);
box-shadow: 0 0 0 1px var(--primary-light);
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
gap: 0.5rem;
margin-top: 0.5rem;
}
.form-actions button {
padding: 0.75rem 1.5rem;
padding: 0.4rem 1rem;
border: none;
border-radius: var(--border-radius-small);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
@@ -785,8 +855,9 @@ export default {
}
.teams-list h3 {
margin-bottom: 1rem;
margin-bottom: 0.5rem;
color: var(--text-color);
font-size: 1.1rem;
}
.no-teams {
@@ -808,7 +879,7 @@ export default {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 1.5rem;
padding: 0.75rem;
cursor: pointer;
transition: var(--transition);
}
@@ -823,13 +894,13 @@ export default {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
margin-bottom: 0.5rem;
}
.team-header h4 {
margin: 0;
color: var(--text-color);
font-size: 1.25rem;
font-size: 1.05rem;
}
.team-actions {
@@ -857,18 +928,20 @@ export default {
.team-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.3rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
}
.info-row .label {
font-weight: 600;
color: var(--text-muted);
font-size: 0.8rem;
}
.info-row .value {
@@ -892,27 +965,27 @@ export default {
/* Upload-Buttons Styles */
.upload-actions {
margin-top: 2rem;
padding: 1.5rem;
margin-top: 1rem;
padding: 0.75rem;
background: var(--background-light);
border-radius: var(--border-radius-medium);
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
}
.upload-actions h4 {
margin: 0 0 1rem 0;
margin: 0 0 0.4rem 0;
color: var(--text-color);
font-size: 1.1rem;
font-size: 0.95rem;
}
.upload-buttons {
display: flex;
gap: 1rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.upload-btn {
padding: 0.75rem 1.5rem;
padding: 0.4rem 1rem;
border: none;
border-radius: var(--border-radius-small);
font-weight: 600;
@@ -920,8 +993,8 @@ export default {
transition: var(--transition);
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
gap: 0.4rem;
font-size: 0.875rem;
}
.code-list-btn {
@@ -954,25 +1027,25 @@ export default {
/* Upload-Bestätigung */
.upload-confirmation {
margin-top: 1.5rem;
padding: 1.5rem;
margin-top: 0.75rem;
padding: 0.75rem;
background: #f8f9fa;
border-radius: var(--border-radius-medium);
border: 2px solid #dee2e6;
border-radius: var(--border-radius);
border: 1px solid #dee2e6;
}
.selected-file-info {
background: #e9ecef;
padding: 1rem;
padding: 0.5rem;
border-radius: var(--border-radius-small);
margin-bottom: 1rem;
font-size: 0.9rem;
margin-bottom: 0.5rem;
font-size: 0.85rem;
line-height: 1.4;
}
.action-buttons {
display: flex;
gap: 1rem;
gap: 0.5rem;
align-items: center;
}
@@ -981,12 +1054,12 @@ export default {
background: #4caf50;
color: white;
border: none;
padding: 0.75rem 1.5rem;
padding: 0.4rem 1rem;
border-radius: var(--border-radius-small);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
margin-right: 1rem;
}
.confirm-parse-btn:hover:not(:disabled) {
@@ -1002,8 +1075,9 @@ export default {
background: #6c757d;
color: white;
border: none;
padding: 0.75rem 1.5rem;
padding: 0.4rem 1rem;
border-radius: var(--border-radius-small);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
@@ -1042,43 +1116,53 @@ export default {
/* PDF-Parsing Styles */
.pdf-parsing-section {
margin-top: 2rem;
padding: 1.5rem;
margin-top: 1rem;
padding: 0.75rem;
background: var(--background-light);
border-radius: var(--border-radius-medium);
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
}
.pdf-parsing-section h4 {
margin: 0 0 1rem 0;
margin: 0 0 0.4rem 0;
color: var(--text-color);
font-size: 1.1rem;
font-size: 0.95rem;
}
.document-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.document-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
.document-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: var(--border-radius-small);
border: 1px solid var(--border-color);
overflow: hidden;
}
.document-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
.document-table th {
background: var(--background-light);
padding: 0.25rem 0.4rem;
text-align: left;
font-weight: 600;
font-size: 0.85rem;
border-bottom: 2px solid var(--border-color);
color: var(--text-color);
}
.document-table td {
padding: 0.25rem 0.4rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.85rem;
}
.document-row:hover {
background: var(--background-light);
}
.document-row:last-child td {
border-bottom: none;
}
.document-name {
font-weight: 600;
font-weight: 500;
color: var(--text-color);
}
@@ -1103,15 +1187,36 @@ export default {
align-items: center;
}
.type-badge {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
border-radius: var(--border-radius-small);
display: inline-block;
white-space: nowrap;
font-weight: 600;
}
.type-badge.code_list {
background: #e3f2fd;
color: #1976d2;
}
.type-badge.pin_list {
background: #fff3e0;
color: #f57c00;
}
.parse-btn {
padding: 0.5rem 1rem;
padding: 0.3rem 0.6rem;
background: #2196F3;
color: white;
border: none;
border-radius: var(--border-radius-small);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.parse-btn:hover:not(:disabled) {
@@ -1121,6 +1226,7 @@ export default {
.parse-btn:disabled {
background: var(--text-muted);
cursor: not-allowed;
opacity: 0.5;
}
/* Team-Dokumente Styles */
@@ -1131,10 +1237,10 @@ export default {
}
.documents-label {
font-size: 0.875rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.5rem;
margin-bottom: 0.3rem;
}
.document-icons {

View File

@@ -133,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}`) }]"
@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)"
@@ -171,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>
@@ -415,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">
@@ -713,7 +713,8 @@ export default {
matchId: null, // aktuell bearbeitetes Match
set: null, // aktuell bearbeitete SatzNummer
value: '' // Eingabewert
}
},
activeMatchId: null // Angeklicktes/aktives Match (für Hervorhebung)
};
},
computed: {
@@ -723,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')
@@ -1259,6 +1273,7 @@ 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(() => {
@@ -1421,6 +1436,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}"]`);
@@ -1922,11 +1940,14 @@ button {
/* Eingabefeld für Sätze - nur so breit wie nötig für die Eingabe */
.inline-input {
width: 5ch; /* 5 Zeichen breit */
padding: 0.25rem 0.5rem;
font-family: monospace; /* Für gleichmäßige Breite der Zeichen */
padding: calc(0.2rem + 2px) calc(0.4rem + 2px);
font-family: inherit;
text-align: center;
border: 1px solid #ccc;
border: 1px solid #dee2e6;
border-radius: 3px;
box-sizing: border-box;
line-height: 1.5;
font-size: inherit;
}
/* Klickbare Ergebnis-Texte */
@@ -1934,11 +1955,42 @@ button {
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: 3px;
transition: background-color 0.2s ease;
border: 1px solid transparent;
transition: all 0.2s ease;
line-height: 1.5;
display: inline-block;
}
.result-text.clickable:hover {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
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>

View File

@@ -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>
@@ -146,6 +158,54 @@ 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 total = this.activeMembers.reduce((sum, member) => sum + this.getMemberParticipationInPeriod(member, currentYear, currentMonth, currentYear, currentMonth), 0);
return total / 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 total = this.activeMembers.reduce((sum, member) => sum + this.getMemberParticipationInPeriod(member, year, actualMonth, year, actualMonth), 0);
return total / trainingsLastMonth;
},
averageParticipationQuarter() {
const now = new Date();
const quarterStartMonth = Math.floor(now.getMonth() / 3) * 3;
const quarterEndMonth = quarterStartMonth + 2;
const trainingsQuarter = this.getTrainingsInPeriod(now.getFullYear(), quarterStartMonth, now.getFullYear(), quarterEndMonth);
if (trainingsQuarter === 0) return 0;
const total = this.activeMembers.reduce((sum, member) => sum + this.getMemberParticipationInPeriod(member, now.getFullYear(), quarterStartMonth, now.getFullYear(), quarterEndMonth), 0);
return total / trainingsQuarter;
},
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 total = this.activeMembers.reduce((sum, member) => sum + this.getMemberParticipationInPeriod(member, now.getFullYear(), halfYearStartMonth, now.getFullYear(), halfYearEndMonth), 0);
return total / trainingsHalfYear;
},
averageParticipationYear() {
const currentYear = new Date().getFullYear();
const trainingsYear = this.getTrainingsInPeriod(currentYear, 0, currentYear, 11);
if (trainingsYear === 0) return 0;
const total = this.activeMembers.reduce((sum, member) => sum + this.getMemberParticipationInPeriod(member, currentYear, 0, currentYear, 11), 0);
return total / trainingsYear;
},
sortedMembers() {
if (!this.activeMembers.length) return [];
@@ -224,6 +284,34 @@ 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;
},
getMemberParticipationInPeriod(member, startYear, startMonth, endYear, endMonth) {
// Vereinfachte Berechnung basierend auf verfügbaren Daten
// Da wir keine detaillierten Teilnahmedaten haben, verwenden wir eine Schätzung
// basierend auf den vorhandenen participation12Months und participation3Months
const totalTrainings = this.getTrainingsInPeriod(startYear, startMonth, endYear, endMonth);
if (totalTrainings === 0) return 0;
// Schätzung basierend auf 12-Monats-Durchschnitt
const avgParticipationRate = member.participation12Months / this.trainingsCount12Months;
return Math.round(totalTrainings * avgParticipationRate);
},
toggleTrainingDays() {
this.showTrainingDays = !this.showTrainingDays;
@@ -300,14 +388,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;
@@ -315,15 +403,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);
}