feat(TrainingStats): enhance training statistics view with collapsible panels and localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s

- Refactored the TrainingStatsView to implement collapsible sections for better organization of training statistics.
- Added new localization keys for training statistics panels in both German and English.
- Updated the mobile app's TrainingStatsScreen to utilize the new collapsible panel structure, improving user experience.
- Enhanced the MembersManager to merge training statistics into member data, providing a comprehensive view of member participation.
- Introduced new API methods for quick updates and transfers of member data, streamlining member management processes.
This commit is contained in:
Torsten Schulz (local)
2026-05-14 16:15:19 +02:00
parent 7981371136
commit 6ef1d79a5f
17 changed files with 2852 additions and 373 deletions

View File

@@ -1353,7 +1353,14 @@
"participationsTotal": "Teilnahmen (Gesamt)",
"lastTraining": "Letztes Training",
"actions": "Aktionen",
"showDetails": "Details anzeigen"
"showDetails": "Details anzeigen",
"panelSummary": "Kennzahlen (Filter)",
"panelMonthlyTrend": "Monatlicher Verlauf",
"panelWeekdayStats": "Trainingstage nach Wochentag",
"panelMemberStructure": "Mitgliederstruktur",
"panelBestDay": "Stärkster Trainingstag",
"panelGroupPerformance": "Entwicklung pro Gruppe",
"panelAgeGroups": "Anwesenheit nach Altersklasse"
},
"tournament": {
"apply": "Übernehmen"

View File

@@ -975,7 +975,14 @@
"participationsTotal": "Participations (total)",
"lastTraining": "Last training",
"actions": "Actions",
"showDetails": "Show details"
"showDetails": "Show details",
"panelSummary": "Key figures (filtered)",
"panelMonthlyTrend": "Monthly trend",
"panelWeekdayStats": "Training days by weekday",
"panelMemberStructure": "Member structure",
"panelBestDay": "Busiest training day",
"panelGroupPerformance": "Progress by group",
"panelAgeGroups": "Attendance by age class"
},
"courtDrawingTool": {
"title": "Table tennis exercise drawing",

View File

@@ -26,153 +26,194 @@
</label>
</div>
<div class="stats-overview">
<div class="stats-summary">
<div class="stat-card">
<h3>Aktive Mitglieder</h3>
<div class="stat-number">{{ filteredMembers.length }}</div>
</div>
<div class="stat-card">
<h3>Trainingstage 12 Monate</h3>
<div class="stat-number">{{ filteredTrainingDays.length }}</div>
</div>
<div class="stat-card">
<h3>Ø Teilnehmer je Training</h3>
<div class="stat-number">{{ filteredOverview.averageParticipants.toFixed(1) }}</div>
</div>
<div class="stat-card">
<h3>Teilnahmen gesamt</h3>
<div class="stat-number">{{ filteredOverview.totalParticipants }}</div>
</div>
<div class="stat-card">
<h3>Anwesenheitsquote 12 Monate</h3>
<div class="stat-number">{{ filteredOverview.attendanceRate.toFixed(1) }}%</div>
</div>
<div class="stat-card">
<h3>Nicht im Training</h3>
<div class="stat-number">{{ filteredMembers.filter((member) => member.notInTraining).length }}</div>
<div class="collapsible-section">
<div class="section-header" @click="togglePanel('overview')">
<h3>{{ $t('trainingStats.panelSummary') }}</h3>
<span class="section-header-right">
<span class="toggle-icon">{{ panels.overview ? '▼' : '▶' }}</span>
</span>
</div>
<div v-show="panels.overview" class="section-content">
<div class="stats-overview panel-body-padding">
<div class="stats-summary">
<div class="stat-card">
<h3>Aktive Mitglieder</h3>
<div class="stat-number">{{ filteredMembers.length }}</div>
</div>
<div class="stat-card">
<h3>Trainingstage 12 Monate</h3>
<div class="stat-number">{{ filteredTrainingDays.length }}</div>
</div>
<div class="stat-card">
<h3>Ø Teilnehmer je Training</h3>
<div class="stat-number">{{ filteredOverview.averageParticipants.toFixed(1) }}</div>
</div>
<div class="stat-card">
<h3>Teilnahmen gesamt</h3>
<div class="stat-number">{{ filteredOverview.totalParticipants }}</div>
</div>
<div class="stat-card">
<h3>Anwesenheitsquote 12 Monate</h3>
<div class="stat-number">{{ filteredOverview.attendanceRate.toFixed(1) }}%</div>
</div>
<div class="stat-card">
<h3>Nicht im Training</h3>
<div class="stat-number">{{ filteredMembers.filter((member) => member.notInTraining).length }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="stats-panels-grid">
<section class="stats-panel">
<div class="panel-header">
<h3>Monatlicher Verlauf</h3>
<span>{{ filteredMonthlyTrend.length }} Monate</span>
<div class="collapsible-section stats-panel-collapsible">
<div class="section-header" @click="togglePanel('monthlyTrend')">
<h3>{{ $t('trainingStats.panelMonthlyTrend') }}</h3>
<span class="section-header-right">
<span class="section-meta">{{ filteredMonthlyTrend.length }} Monate</span>
<span class="toggle-icon">{{ panels.monthlyTrend ? '▼' : '▶' }}</span>
</span>
</div>
<div class="trend-list">
<div v-for="month in filteredMonthlyTrend" :key="month.key" class="trend-row">
<div class="trend-meta">
<strong>{{ month.label }}</strong>
<span>{{ month.trainingCount }} Trainingstage</span>
</div>
<div class="trend-bar-track">
<div class="trend-bar-fill" :style="{ width: `${getMonthBarWidth(month.averageParticipants)}%` }"></div>
</div>
<div class="trend-value">{{ month.averageParticipants.toFixed(1) }}</div>
</div>
</div>
</section>
<section class="stats-panel">
<div class="panel-header">
<h3>Trainingstage nach Wochentag</h3>
<span>{{ filteredWeekdayStats.length }} Wochentage</span>
</div>
<div class="weekday-grid">
<div v-for="day in filteredWeekdayStats" :key="day.weekday" class="weekday-card">
<strong>{{ day.weekday }}</strong>
<span>{{ day.trainingCount }} Termine</span>
<span>Ø {{ day.averageParticipants.toFixed(1) }} Teilnehmer</span>
</div>
</div>
</section>
<section class="stats-panel">
<div class="panel-header">
<h3>Mitgliederstruktur</h3>
</div>
<div class="distribution-grid">
<div class="distribution-card">
<strong>Sehr aktiv</strong>
<span>{{ filteredMemberDistribution.highlyActive }}</span>
<small>mind. 75 % der Trainingstage</small>
</div>
<div class="distribution-card">
<strong>Regelmäßig</strong>
<span>{{ filteredMemberDistribution.regular }}</span>
<small>40 bis unter 75 %</small>
</div>
<div class="distribution-card">
<strong>Gelegentlich</strong>
<span>{{ filteredMemberDistribution.occasional }}</span>
<small>unter 40 %</small>
</div>
<div class="distribution-card">
<strong>Ohne Teilnahme</strong>
<span>{{ filteredMemberDistribution.inactive }}</span>
<small>0 Teilnahmen in 12 Monaten</small>
</div>
</div>
</section>
<section class="stats-panel stats-panel-highlight">
<div class="panel-header">
<h3>Stärkster Trainingstag</h3>
</div>
<div v-if="filteredOverview.bestTrainingDay" class="highlight-block">
<strong>{{ formatDate(filteredOverview.bestTrainingDay.date) }}</strong>
<span>{{ getWeekday(filteredOverview.bestTrainingDay.date) }}</span>
<div class="highlight-number">{{ filteredOverview.bestTrainingDay.participantCount }}</div>
<small>Teilnehmer beim bestbesuchten Training der letzten 12 Monate</small>
</div>
<div v-else class="highlight-block">
<strong>Keine Daten</strong>
</div>
</section>
<section class="stats-panel">
<div class="panel-header">
<h3>Entwicklung pro Gruppe</h3>
<span>{{ groupPerformance.length }} Gruppen</span>
</div>
<div class="group-performance-list">
<div v-for="group in groupPerformance" :key="group.name" class="group-performance-row">
<div class="group-performance-meta">
<strong>{{ group.name }}</strong>
<span>{{ group.memberCount }} Mitglieder</span>
</div>
<div class="group-performance-stats">
<span>{{ group.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate</span>
<span>{{ group.participationRate.toFixed(1) }}% Anwesenheit</span>
<div v-show="panels.monthlyTrend" class="section-content">
<div class="trend-list panel-body-padding">
<div v-for="month in filteredMonthlyTrend" :key="month.key" class="trend-row">
<div class="trend-meta">
<strong>{{ month.label }}</strong>
<span>{{ month.trainingCount }} Trainingstage</span>
</div>
<div class="trend-bar-track">
<div class="trend-bar-fill" :style="{ width: `${getMonthBarWidth(month.averageParticipants)}%` }"></div>
</div>
<div class="trend-value">{{ month.averageParticipants.toFixed(1) }}</div>
</div>
</div>
</div>
</section>
</div>
<section class="stats-panel">
<div class="panel-header">
<h3>Anwesenheit nach Altersklasse</h3>
<span>{{ ageGroupStats.length }} Klassen</span>
<div class="collapsible-section stats-panel-collapsible">
<div class="section-header" @click="togglePanel('weekdayStats')">
<h3>{{ $t('trainingStats.panelWeekdayStats') }}</h3>
<span class="section-header-right">
<span class="section-meta">{{ filteredWeekdayStats.length }} Wochentage</span>
<span class="toggle-icon">{{ panels.weekdayStats ? '▼' : '▶' }}</span>
</span>
</div>
<div class="distribution-grid">
<div v-for="entry in ageGroupStats" :key="entry.label" class="distribution-card">
<strong>{{ entry.label }}</strong>
<span>{{ entry.memberCount }}</span>
<small>{{ entry.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate</small>
<div v-show="panels.weekdayStats" class="section-content">
<div class="weekday-grid panel-body-padding">
<div v-for="day in filteredWeekdayStats" :key="day.weekday" class="weekday-card">
<strong>{{ day.weekday }}</strong>
<span>{{ day.trainingCount }} Termine</span>
<span>Ø {{ day.averageParticipants.toFixed(1) }} Teilnehmer</span>
</div>
</div>
</div>
</section>
</div>
<div class="collapsible-section stats-panel-collapsible">
<div class="section-header" @click="togglePanel('memberStructure')">
<h3>{{ $t('trainingStats.panelMemberStructure') }}</h3>
<span class="section-header-right">
<span class="toggle-icon">{{ panels.memberStructure ? '▼' : '▶' }}</span>
</span>
</div>
<div v-show="panels.memberStructure" class="section-content">
<div class="distribution-grid panel-body-padding">
<div class="distribution-card">
<strong>Sehr aktiv</strong>
<span>{{ filteredMemberDistribution.highlyActive }}</span>
<small>mind. 75 % der Trainingstage</small>
</div>
<div class="distribution-card">
<strong>Regelmäßig</strong>
<span>{{ filteredMemberDistribution.regular }}</span>
<small>40 bis unter 75 %</small>
</div>
<div class="distribution-card">
<strong>Gelegentlich</strong>
<span>{{ filteredMemberDistribution.occasional }}</span>
<small>unter 40 %</small>
</div>
<div class="distribution-card">
<strong>Ohne Teilnahme</strong>
<span>{{ filteredMemberDistribution.inactive }}</span>
<small>0 Teilnahmen in 12 Monaten</small>
</div>
</div>
</div>
</div>
<div class="collapsible-section stats-panel-collapsible stats-panel-highlight">
<div class="section-header" @click="togglePanel('bestDay')">
<h3>{{ $t('trainingStats.panelBestDay') }}</h3>
<span class="section-header-right">
<span class="toggle-icon">{{ panels.bestDay ? '▼' : '▶' }}</span>
</span>
</div>
<div v-show="panels.bestDay" class="section-content">
<div class="panel-body-padding">
<div v-if="filteredOverview.bestTrainingDay" class="highlight-block">
<strong>{{ formatDate(filteredOverview.bestTrainingDay.date) }}</strong>
<span>{{ getWeekday(filteredOverview.bestTrainingDay.date) }}</span>
<div class="highlight-number">{{ filteredOverview.bestTrainingDay.participantCount }}</div>
<small>Teilnehmer beim bestbesuchten Training der letzten 12 Monate</small>
</div>
<div v-else class="highlight-block">
<strong>Keine Daten</strong>
</div>
</div>
</div>
</div>
<div class="collapsible-section stats-panel-collapsible">
<div class="section-header" @click="togglePanel('groupPerformance')">
<h3>{{ $t('trainingStats.panelGroupPerformance') }}</h3>
<span class="section-header-right">
<span class="section-meta">{{ groupPerformance.length }} Gruppen</span>
<span class="toggle-icon">{{ panels.groupPerformance ? '▼' : '▶' }}</span>
</span>
</div>
<div v-show="panels.groupPerformance" class="section-content">
<div class="group-performance-list panel-body-padding">
<div v-for="group in groupPerformance" :key="group.name" class="group-performance-row">
<div class="group-performance-meta">
<strong>{{ group.name }}</strong>
<span>{{ group.memberCount }} Mitglieder</span>
</div>
<div class="group-performance-stats">
<span>{{ group.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate</span>
<span>{{ group.participationRate.toFixed(1) }}% Anwesenheit</span>
</div>
</div>
</div>
</div>
</div>
<div class="collapsible-section stats-panel-collapsible">
<div class="section-header" @click="togglePanel('ageGroup')">
<h3>{{ $t('trainingStats.panelAgeGroups') }}</h3>
<span class="section-header-right">
<span class="section-meta">{{ ageGroupStats.length }} Klassen</span>
<span class="toggle-icon">{{ panels.ageGroup ? '▼' : '▶' }}</span>
</span>
</div>
<div v-show="panels.ageGroup" class="section-content">
<div class="distribution-grid panel-body-padding">
<div v-for="entry in ageGroupStats" :key="entry.label" class="distribution-card">
<strong>{{ entry.label }}</strong>
<span>{{ entry.memberCount }}</span>
<small>{{ entry.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate</small>
</div>
</div>
</div>
</div>
</div>
<!-- Trainingstage-Tabelle (standardmäßig aufgeklappt) -->
<div class="collapsible-section">
<div class="section-header" @click="toggleTrainingDays">
<div class="section-header" @click="togglePanel('trainingDays')">
<h3>{{ $t('trainingStats.trainingDays') }}</h3>
<span class="toggle-icon">{{ showTrainingDays ? '▼' : '▶' }}</span>
<span class="toggle-icon">{{ panels.trainingDays ? '▼' : '▶' }}</span>
</div>
<div v-if="showTrainingDays" class="section-content">
<div v-show="panels.trainingDays" class="section-content">
<div class="training-days-container">
<table class="training-days-table">
<thead>
@@ -207,13 +248,13 @@
</div>
</div>
<!-- Mitglieder-Tabelle (standardmäßig eingeklappt) -->
<!-- Mitglieder-Tabelle -->
<div class="collapsible-section">
<div class="section-header" @click="toggleMembers">
<div class="section-header" @click="togglePanel('members')">
<h3>{{ $t('trainingStats.memberParticipations') }}</h3>
<span class="toggle-icon">{{ showMembers ? '▼' : '▶' }}</span>
<span class="toggle-icon">{{ panels.members ? '▼' : '▶' }}</span>
</div>
<div v-if="showMembers" class="section-content">
<div v-show="panels.members" class="section-content">
<div class="members-table-container">
<table class="members-table">
<thead>
@@ -576,8 +617,17 @@ export default {
loading: false,
sortField: 'name',
sortDirection: 'asc',
showTrainingDays: true,
showMembers: false
panels: {
overview: false,
monthlyTrend: false,
weekdayStats: false,
memberStructure: false,
bestDay: false,
groupPerformance: false,
ageGroup: false,
trainingDays: false,
members: false
}
};
},
@@ -626,15 +676,12 @@ export default {
this.loading = false;
}
},
toggleTrainingDays() {
this.showTrainingDays = !this.showTrainingDays;
togglePanel(key) {
if (!Object.prototype.hasOwnProperty.call(this.panels, key)) return;
this.panels = { ...this.panels, [key]: !this.panels[key] };
},
toggleMembers() {
this.showMembers = !this.showMembers;
},
getWeekday(dateString) {
const date = new Date(dateString);
const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
@@ -779,6 +826,42 @@ export default {
margin-bottom: 1.5rem;
}
.stats-panels-grid > .collapsible-section {
margin-bottom: 0;
}
.stats-panel-collapsible .section-header {
margin-bottom: 0;
}
.section-header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-meta {
font-size: 0.85rem;
color: var(--text-secondary);
}
.section-header:hover .section-meta {
color: inherit;
opacity: 0.95;
}
.panel-body-padding {
padding: 0 1.15rem 1.15rem;
}
.stats-overview.panel-body-padding {
margin-bottom: 0;
}
.collapsible-section.stats-panel-highlight {
background: linear-gradient(135deg, rgba(47, 122, 95, 0.08), rgba(255, 255, 255, 0.98));
}
.stats-panel {
background: white;
border: 1px solid var(--border-color);