feat(TrainingStatsService, MembersView, TrainingStatsView): enhance training statistics and member management features

- Added new functions in TrainingStatsService to calculate monthly trends and member distribution based on training participation.
- Updated MembersView to improve the display of training groups and address potential data entry issues with visual hints.
- Enhanced TrainingStatsView with new filters for weekdays and training groups, and improved the layout for displaying training statistics, including average participation and attendance rates.
- Introduced additional statistics panels for better insights into training performance and member engagement.
This commit is contained in:
Torsten Schulz (local)
2026-03-18 21:07:52 +01:00
parent dc15b48b80
commit b13d33c72c
4 changed files with 861 additions and 149 deletions

View File

@@ -1,4 +1,4 @@
import { DiaryDate, Member, Participant } from '../models/index.js';
import { DiaryDate, Member, Participant, TrainingGroup } from '../models/index.js';
import { Op } from 'sequelize';
function getIsoWeekKey(dateLike) {
@@ -36,6 +36,36 @@ function countMissedTrainingWeeks(trainingDates, lastTrainingDate) {
return weeks.size;
}
function getMonthKey(dateLike) {
const date = new Date(dateLike);
if (Number.isNaN(date.getTime())) {
return null;
}
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
function buildLastMonthsTemplate(monthCount = 12) {
const months = [];
const cursor = new Date();
cursor.setDate(1);
for (let i = monthCount - 1; i >= 0; i -= 1) {
const monthDate = new Date(cursor.getFullYear(), cursor.getMonth() - i, 1);
months.push({
key: getMonthKey(monthDate),
year: monthDate.getFullYear(),
month: monthDate.getMonth(),
label: `${String(monthDate.getMonth() + 1).padStart(2, '0')}.${monthDate.getFullYear()}`,
trainingCount: 0,
participantCount: 0,
averageParticipants: 0,
});
}
return months;
}
class TrainingStatsService {
async getTrainingStats(clubIdRaw) {
const clubId = parseInt(clubIdRaw, 10);
@@ -50,7 +80,14 @@ class TrainingStatsService {
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
const members = await Member.findAll({
where: { active: true, clubId }
where: { active: true, clubId },
include: [{
model: TrainingGroup,
as: 'trainingGroups',
attributes: ['id', 'name'],
through: { attributes: [] },
required: false
}]
});
const trainingsCount12Months = await DiaryDate.count({
@@ -88,7 +125,7 @@ class TrainingStatsService {
date: { [Op.gte]: twelveMonthsAgo }
}
}],
where: { memberId: member.id }
where: { memberId: member.id, attendanceStatus: 'present' }
});
const participation3Months = await Participant.count({
@@ -100,7 +137,7 @@ class TrainingStatsService {
date: { [Op.gte]: threeMonthsAgo }
}
}],
where: { memberId: member.id }
where: { memberId: member.id, attendanceStatus: 'present' }
});
const participationTotal = await Participant.count({
@@ -109,7 +146,7 @@ class TrainingStatsService {
as: 'diaryDate',
where: { clubId }
}],
where: { memberId: member.id }
where: { memberId: member.id, attendanceStatus: 'present' }
});
const trainingDetails = await Participant.findAll({
@@ -118,7 +155,7 @@ class TrainingStatsService {
as: 'diaryDate',
where: { clubId }
}],
where: { memberId: member.id },
where: { memberId: member.id, attendanceStatus: 'present' },
order: [['diaryDate', 'date', 'DESC']],
limit: 50
});
@@ -141,9 +178,15 @@ class TrainingStatsService {
firstName: member.firstName,
lastName: member.lastName,
birthDate: member.birthDate,
ttr: member.ttr ?? null,
qttr: member.qttr ?? null,
trainingGroups: Array.isArray(member.trainingGroups)
? member.trainingGroups.map((group) => ({ id: group.id, name: group.name }))
: [],
participation12Months,
participation3Months,
participationTotal,
participationRate12Months: trainingsCount12Months > 0 ? participation12Months / trainingsCount12Months : 0,
lastTraining: lastTrainingDate,
lastTrainingTs,
missedTrainingWeeks,
@@ -162,7 +205,7 @@ class TrainingStatsService {
include: [{
model: Participant,
as: 'participantList',
attributes: ['id']
attributes: ['id', 'attendanceStatus']
}],
order: [['date', 'DESC']]
});
@@ -170,14 +213,85 @@ class TrainingStatsService {
const formattedTrainingDays = trainingDays.map((day) => ({
id: day.id,
date: day.date,
participantCount: day.participantList ? day.participantList.length : 0
participantCount: day.participantList
? day.participantList.filter((participant) => !participant.attendanceStatus || participant.attendanceStatus === 'present').length
: 0
}));
const totalParticipants12Months = formattedTrainingDays.reduce((sum, day) => sum + (day.participantCount || 0), 0);
const averageParticipants12Months = trainingsCount12Months > 0 ? totalParticipants12Months / trainingsCount12Months : 0;
const attendanceRate12Months = (members.length > 0 && trainingsCount12Months > 0)
? (totalParticipants12Months / (members.length * trainingsCount12Months)) * 100
: 0;
const inactiveMembersCount = stats.filter((member) => member.notInTraining).length;
const bestTrainingDay = formattedTrainingDays.reduce((best, day) => {
if (!best || (day.participantCount || 0) > (best.participantCount || 0)) {
return day;
}
return best;
}, null);
const weekdayNames = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
const weekdayBuckets = weekdayNames.map((name, index) => ({
weekday: name,
weekdayIndex: index,
trainingCount: 0,
participantCount: 0,
averageParticipants: 0,
}));
const monthlyTrend = buildLastMonthsTemplate(12);
const monthlyTrendMap = new Map(monthlyTrend.map((entry) => [entry.key, entry]));
for (const day of formattedTrainingDays) {
const date = new Date(day.date);
if (Number.isNaN(date.getTime())) {
continue;
}
const weekdayEntry = weekdayBuckets[date.getDay()];
weekdayEntry.trainingCount += 1;
weekdayEntry.participantCount += day.participantCount || 0;
const monthKey = getMonthKey(day.date);
if (monthKey && monthlyTrendMap.has(monthKey)) {
const monthEntry = monthlyTrendMap.get(monthKey);
monthEntry.trainingCount += 1;
monthEntry.participantCount += day.participantCount || 0;
}
}
weekdayBuckets.forEach((entry) => {
entry.averageParticipants = entry.trainingCount > 0 ? entry.participantCount / entry.trainingCount : 0;
});
monthlyTrend.forEach((entry) => {
entry.averageParticipants = entry.trainingCount > 0 ? entry.participantCount / entry.trainingCount : 0;
});
const memberDistribution = {
highlyActive: stats.filter((member) => member.participationRate12Months >= 0.75).length,
regular: stats.filter((member) => member.participationRate12Months >= 0.4 && member.participationRate12Months < 0.75).length,
occasional: stats.filter((member) => member.participationRate12Months > 0 && member.participationRate12Months < 0.4).length,
inactive: stats.filter((member) => member.participation12Months === 0).length,
};
return {
members: stats,
trainingsCount12Months,
trainingsCount3Months,
trainingDays: formattedTrainingDays
trainingDays: formattedTrainingDays,
overview: {
activeMembersCount: members.length,
totalParticipants12Months,
averageParticipants12Months,
attendanceRate12Months,
inactiveMembersCount,
bestTrainingDay,
},
weekdayStats: weekdayBuckets.filter((entry) => entry.trainingCount > 0),
monthlyTrend,
memberDistribution,
};
}
}

View File

@@ -301,6 +301,8 @@
"editHint": "Ein Klick auf eine Zeile öffnet den Editor.",
"editorCreateHint": "Neues Mitglied anlegen und Kontaktdaten direkt erfassen.",
"editorEditHint": "Daten von {name} bearbeiten.",
"editorAssignTrainingGroupHint": "Bitte mindestens eine Trainingsgruppe zuordnen.",
"editorRecommendedEntry": "Empfohlener Eintrag",
"transferSuccessTitle": "Übertragung erfolgreich",
"transferErrorTitle": "Fehler bei der Übertragung",
"clickTtRequestPending": "Click-TT-Antrag läuft",
@@ -353,12 +355,14 @@
"dataIssueEmail": "E-Mail fehlt",
"dataIssueAddress": "Adresse fehlt",
"dataIssueGender": "Geschlecht ungeklärt",
"dataIssueTrainingGroup": "Trainingsgruppe fehlt",
"openTasks": "Offene Aufgaben",
"noOpenTasks": "Keine offenen Aufgaben",
"taskVerifyForm": "Formular prüfen",
"taskReviewTrialStatus": "Probe-Status prüfen",
"taskCheckTrainingStatus": "Trainingsstatus prüfen",
"taskCheckDataQuality": "Datenqualität prüfen",
"taskAssignTrainingGroup": "Trainingsgruppe zuordnen",
"taskCheckClickTt": "Click-TT-Spielberechtigung prüfen",
"taskActionVerify": "Prüfen",
"taskActionMarkRegular": "Regulär setzen",

View File

@@ -107,9 +107,9 @@
</div>
<div class="member-preview-item">
<span class="member-preview-item-label">{{ $t('members.trainingGroups') }}</span>
<div v-if="selectedPreviewTrainingGroups.length" class="member-preview-tags">
<div v-if="getPreviewTrainingGroups(selectedMemberPreview).length" class="member-preview-tags">
<span
v-for="group in selectedPreviewTrainingGroups"
v-for="group in getPreviewTrainingGroups(selectedMemberPreview)"
:key="group.id"
class="member-preview-tag"
>
@@ -184,16 +184,33 @@
<div class="new-member-form">
<label><span>{{ $t('members.firstName') }}:</span> <input type="text" v-model="newFirstname"></label>
<label><span>{{ $t('members.lastName') }}:</span> <input type="text" v-model="newLastname"></label>
<label><span>{{ $t('members.street') }}:</span> <input type="text" v-model="newStreet"></label>
<label><span>{{ $t('members.postalCode') }}:</span> <input type="text" v-model="newPostalCode" maxlength="10"></label>
<label><span>{{ $t('members.city') }}:</span> <input type="text" v-model="newCity"></label>
<label><span>{{ $t('members.birthdate') }}:</span> <input type="date" v-model="newBirthdate"></label>
<label :class="{ 'member-field-warning': editorHasIssue('address') }">
<span>{{ $t('members.street') }}:</span>
<input type="text" v-model="newStreet" :class="{ 'input-warning': editorHasIssue('address') }">
<small v-if="editorHasIssue('address')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
</label>
<label :class="{ 'member-field-warning': editorHasIssue('address') }">
<span>{{ $t('members.postalCode') }}:</span>
<input type="text" v-model="newPostalCode" maxlength="10" :class="{ 'input-warning': editorHasIssue('address') }">
<small v-if="editorHasIssue('address')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
</label>
<label :class="{ 'member-field-warning': editorHasIssue('address') }">
<span>{{ $t('members.city') }}:</span>
<input type="text" v-model="newCity" :class="{ 'input-warning': editorHasIssue('address') }">
<small v-if="editorHasIssue('address')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
</label>
<label :class="{ 'member-field-warning': editorHasIssue('birthdate') }">
<span>{{ $t('members.birthdate') }}:</span>
<input type="date" v-model="newBirthdate" :class="{ 'input-warning': editorHasIssue('birthdate') }">
<small v-if="editorHasIssue('birthdate')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
</label>
<!-- Telefonnummern -->
<div class="contact-section">
<div class="contact-section" :class="{ 'member-field-warning-box': editorHasIssue('phone') }">
<label><span>{{ $t('members.phones') }}:</span></label>
<div v-if="editorHasIssue('phone')" class="member-editor-field-hint member-editor-frame-hint">{{ $t('members.editorRecommendedEntry') }}</div>
<div v-for="(phone, index) in memberContacts.phones" :key="'phone-' + index" class="contact-item">
<input type="text" v-model="phone.value" :placeholder="$t('members.phoneNumber')" class="contact-input">
<input type="text" v-model="phone.value" :placeholder="$t('members.phoneNumber')" class="contact-input" :class="{ 'input-warning': editorHasIssue('phone') && !phone.value }">
<label class="checkbox-inline">
<input type="checkbox" v-model="phone.isParent"> {{ $t('members.parent') }}
</label>
@@ -207,10 +224,11 @@
</div>
<!-- E-Mail-Adressen -->
<div class="contact-section">
<div class="contact-section" :class="{ 'member-field-warning-box': editorHasIssue('email') }">
<label><span>{{ $t('members.emails') }}:</span></label>
<div v-if="editorHasIssue('email')" class="member-editor-field-hint member-editor-frame-hint">{{ $t('members.editorRecommendedEntry') }}</div>
<div v-for="(email, index) in memberContacts.emails" :key="'email-' + index" class="contact-item">
<input type="email" v-model="email.value" :placeholder="$t('members.emailAddress')" class="contact-input">
<input type="email" v-model="email.value" :placeholder="$t('members.emailAddress')" class="contact-input" :class="{ 'input-warning': editorHasIssue('email') && !email.value }">
<label class="checkbox-inline">
<input type="checkbox" v-model="email.isParent"> {{ $t('members.parent') }}
</label>
@@ -222,13 +240,14 @@
</div>
<button type="button" @click="addContact('email')" class="btn-add-contact">+ {{ $t('members.addEmail') }}</button>
</div>
<label><span>{{ $t('members.gender') }}:</span>
<select v-model="newGender">
<label :class="{ 'member-field-warning': editorHasIssue('gender') }"><span>{{ $t('members.gender') }}:</span>
<select v-model="newGender" :class="{ 'input-warning': editorHasIssue('gender') }">
<option value="unknown">{{ $t('members.genderUnknown') }}</option>
<option value="male">{{ $t('members.genderMale') }}</option>
<option value="female">{{ $t('members.genderFemale') }}</option>
<option value="diverse">{{ $t('members.genderDiverse') }}</option>
</select>
<small v-if="editorHasIssue('gender')" class="member-editor-field-hint">{{ $t('members.editorRecommendedEntry') }}</small>
</label>
<label class="checkbox-item"><span>{{ $t('members.active') }}:</span> <input type="checkbox" v-model="newActive"></label>
<label class="checkbox-item"><span>{{ $t('members.picsInInternetAllowed') }}:</span> <input type="checkbox" v-model="newPicsInInternetAllowed"></label>
@@ -236,7 +255,7 @@
<label class="checkbox-item"><span>{{ $t('members.memberFormHandedOver') }}:</span> <input type="checkbox" v-model="newMemberFormHandedOver"></label>
<!-- Trainingsgruppen -->
<div class="contact-section" v-if="memberToEdit">
<div class="contact-section" :class="{ 'member-field-warning-box': editorHasIssue('training-group') }" v-if="memberToEdit">
<label><span>{{ $t('members.trainingGroups') }}:</span></label>
<div v-if="memberTrainingGroups.length > 0" class="member-groups-list">
<span
@@ -255,9 +274,11 @@
</span>
</div>
<div v-else class="no-groups-hint">{{ $t('members.noGroupsAssigned') }}</div>
<div v-if="editorHasIssue('training-group')" class="member-editor-field-hint">{{ $t('members.editorAssignTrainingGroupHint') }}</div>
<select
v-model="selectedGroupToAdd"
class="group-select"
:class="{ 'input-warning': editorHasIssue('training-group') }"
@change="addMemberToGroup($event.target.value)"
:disabled="availableGroupsForMember.length === 0"
>
@@ -691,6 +712,43 @@ export default {
return emails;
},
hasEditorPhone() {
return this.memberContacts.phones.some(contact => contact?.value && String(contact.value).trim() !== '');
},
hasEditorEmail() {
return this.memberContacts.emails.some(contact => contact?.value && String(contact.value).trim() !== '');
},
hasEditorAddress() {
return [this.newStreet, this.newPostalCode, this.newCity].some(value => value && String(value).trim() !== '');
},
editorDataQualityIssues() {
const issues = [];
if (!this.newBirthdate) {
issues.push({ key: 'birthdate', label: this.$t('members.dataIssueBirthdate') });
}
if (!this.hasEditorPhone) {
issues.push({ key: 'phone', label: this.$t('members.dataIssuePhone') });
}
if (!this.hasEditorEmail) {
issues.push({ key: 'email', label: this.$t('members.dataIssueEmail') });
}
if (!this.hasEditorAddress) {
issues.push({ key: 'address', label: this.$t('members.dataIssueAddress') });
}
if (!this.newGender || this.newGender === 'unknown') {
issues.push({ key: 'gender', label: this.$t('members.dataIssueGender') });
}
if (this.memberToEdit && this.memberTrainingGroups.length === 0) {
issues.push({ key: 'training-group', label: this.$t('members.dataIssueTrainingGroup') });
}
return issues;
},
exportPreviewNames() {
return this.sortedFilteredMembers
.slice(0, 5)
@@ -922,7 +980,7 @@ export default {
// Erstelle eine Map für schnellen Zugriff: memberId -> participationTotal
// Speichere sowohl String- als auch Number-Keys, um Typ-Probleme zu vermeiden
const participationMap = new Map();
trainingStats.forEach(stat => {
trainingStats.forEach(stat => {
const participationTotal = stat.participationTotal || 0;
// Speichere sowohl als String als auch als Number
const idAsString = String(stat.id);
@@ -955,6 +1013,7 @@ export default {
member.missedTrainingWeeks = matchingStat?.missedTrainingWeeks || 0;
member.notInTraining = !!matchingStat?.notInTraining;
member.lastTraining = matchingStat?.lastTraining || null;
member.trainingGroups = Array.isArray(matchingStat?.trainingGroups) ? matchingStat.trainingGroups : [];
});
} catch (error) {
console.error(this.$t('members.errorLoadingTrainingParticipations'), error);
@@ -963,6 +1022,7 @@ export default {
member.missedTrainingWeeks = 0;
member.notInTraining = false;
member.lastTraining = null;
member.trainingGroups = [];
});
}
},
@@ -2049,6 +2109,9 @@ export default {
if (this.getMemberDataQualityIssues(member).length) {
badges.push({ key: 'data-quality', label: this.$t('members.scopeDataIncomplete'), tone: 'warning' });
}
if (!this.getMemberTrainingGroups(member).length) {
badges.push({ key: 'no-group', label: this.$t('members.noGroupsAssigned'), tone: 'warning' });
}
if (member.notInTraining) {
badges.push({ key: 'not-training', label: this.$t('members.scopeNotTraining'), tone: 'danger' });
}
@@ -2064,6 +2127,7 @@ export default {
const compactAddress = this.getCompactAddress(member);
const hasPhone = this.getFormattedPhoneNumbers(member) !== '';
const hasEmail = this.getFormattedEmails(member) !== '';
const hasTrainingGroup = this.getMemberTrainingGroups(member).length > 0;
if (!member.birthDate) {
issues.push({ key: 'birthdate', label: this.$t('members.dataIssueBirthdate') });
@@ -2080,6 +2144,9 @@ export default {
if (!member.gender || member.gender === 'unknown') {
issues.push({ key: 'gender', label: this.$t('members.dataIssueGender') });
}
if (!hasTrainingGroup) {
issues.push({ key: 'training-group', label: this.$t('members.dataIssueTrainingGroup') });
}
return issues;
},
@@ -2118,6 +2185,14 @@ export default {
actionLabel: this.$t('members.taskActionReview')
});
}
if (!this.getMemberTrainingGroups(member).length) {
tasks.push({
key: 'training-group',
label: this.$t('members.taskAssignTrainingGroup'),
action: 'open-editor',
actionLabel: this.$t('members.taskActionReview')
});
}
if (member.active && !member.ttr && !member.qttr && !member.clickTtApplicationSubmitted) {
tasks.push({
key: 'clicktt',
@@ -2146,6 +2221,19 @@ export default {
this.editMember(member);
}
},
getMemberTrainingGroups(member) {
if (!member) return [];
return Array.isArray(member.trainingGroups) ? member.trainingGroups : [];
},
editorHasIssue(issueKey) {
return this.editorDataQualityIssues.some(issue => issue.key === issueKey);
},
getPreviewTrainingGroups(member) {
if (this.selectedMemberPreview && member && this.selectedMemberPreview.id === member.id && this.selectedPreviewTrainingGroups.length) {
return this.selectedPreviewTrainingGroups;
}
return this.getMemberTrainingGroups(member);
},
openTtrHistoryDialog(member) {
if (!member) {
return;
@@ -2374,11 +2462,115 @@ table td {
gap: 0.8rem;
}
.new-member-form > label {
display: block;
padding: 0.35rem 0;
border: 1px solid transparent;
border-radius: 10px;
}
.new-member-form>label>span {
width: 10em;
display: inline-block;
}
.new-member-form > label.member-field-warning {
padding: 0.7rem 0.85rem;
border-color: #f1d49a;
background: #fffaf0;
}
.member-field-warning > span {
color: #8a5a00;
font-weight: 600;
}
.input-warning {
border-color: #e1a43a !important;
background: #fffaf0 !important;
box-shadow: none;
}
.member-field-warning-box {
position: relative;
padding: 1rem;
border: 1px solid #e1a43a;
background: linear-gradient(180deg, #fff7e6 0%, #fff1d8 100%);
box-shadow: inset 0 0 0 1px rgba(225, 164, 58, 0.12);
}
.member-field-warning-box > label {
color: #8a5a00;
font-weight: 600;
}
.member-field-warning-box .member-editor-field-hint {
margin-left: 0;
}
.member-field-warning-box .member-editor-frame-hint {
position: absolute;
top: 0;
left: 0.85rem;
display: inline-block;
margin: 0;
padding: 0 0.45rem;
transform: translateY(-50%);
background: #fffdf8;
color: #8a5a00;
font-size: 0.82rem;
line-height: 1.2;
}
.member-field-warning-box .contact-item,
.member-field-warning-box .member-groups-list,
.member-field-warning-box .no-groups-hint,
.member-field-warning-box .group-select,
.member-field-warning-box .checkbox-inline,
.member-field-warning-box .btn-add-contact {
background: rgba(225, 164, 58, 0.12);
border-radius: 8px;
}
.member-field-warning-box .contact-item,
.member-field-warning-box .no-groups-hint,
.member-field-warning-box .checkbox-inline {
padding: 0.45rem 0.55rem;
}
.member-field-warning-box .member-groups-list {
padding: 0.55rem;
}
.member-field-warning-box .contact-input,
.member-field-warning-box .parent-name-input,
.member-field-warning-box .group-select {
border-color: #e1a43a;
background: #fffaf0;
}
.member-field-warning-box .checkbox-inline {
border: 1px solid rgba(225, 164, 58, 0.2);
}
.member-field-warning-box .btn-add-contact {
border-color: rgba(225, 164, 58, 0.35);
background: #f8e2b7;
color: #704600;
}
.member-field-warning-box .btn-add-contact:hover {
background: #f3d79f;
}
.member-editor-field-hint {
display: block;
margin-top: 0.35rem;
margin-left: 10em;
color: #8a5a00;
font-size: 0.88rem;
}
.modal {
display: flex;
justify-content: center;

View File

@@ -1,36 +1,164 @@
<template>
<div class="training-stats">
<h2>{{ $t('trainingStats.title') }}</h2>
<div class="stats-filters">
<label class="stats-filter">
<span>Wochentag</span>
<select v-model="selectedWeekday">
<option value="all">Alle</option>
<option v-for="option in weekdayOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</label>
<label class="stats-filter">
<span>Trainingsgruppe</span>
<select v-model="selectedTrainingGroup">
<option value="all">Alle</option>
<option v-for="group in trainingGroupOptions" :key="group.value" :value="group.value">{{ group.label }}</option>
</select>
</label>
</div>
<div class="stats-overview">
<div class="stats-summary">
<div class="stat-card">
<h3>{{ $t('trainingStats.activeMembers') }}</h3>
<div class="stat-number">{{ activeMembers.length }}</div>
<h3>Aktive Mitglieder</h3>
<div class="stat-number">{{ filteredMembers.length }}</div>
</div>
<div class="stat-card">
<h3>{{ $t('trainingStats.averageParticipationCurrentMonth') }}</h3>
<div class="stat-number">{{ averageParticipationCurrentMonth.toFixed(1) }}</div>
<h3>Trainingstage 12 Monate</h3>
<div class="stat-number">{{ filteredTrainingDays.length }}</div>
</div>
<div class="stat-card">
<h3>{{ $t('trainingStats.averageParticipationLastMonth') }}</h3>
<div class="stat-number">{{ averageParticipationLastMonth.toFixed(1) }}</div>
<h3>Ø Teilnehmer je Training</h3>
<div class="stat-number">{{ filteredOverview.averageParticipants.toFixed(1) }}</div>
</div>
<div class="stat-card">
<h3>{{ $t('trainingStats.averageParticipationQuarter') }}</h3>
<div class="stat-number">{{ averageParticipationQuarter.toFixed(1) }}</div>
<h3>Teilnahmen gesamt</h3>
<div class="stat-number">{{ filteredOverview.totalParticipants }}</div>
</div>
<div class="stat-card">
<h3>{{ $t('trainingStats.averageParticipationHalfYear') }}</h3>
<div class="stat-number">{{ averageParticipationHalfYear.toFixed(1) }}</div>
<h3>Anwesenheitsquote 12 Monate</h3>
<div class="stat-number">{{ filteredOverview.attendanceRate.toFixed(1) }}%</div>
</div>
<div class="stat-card">
<h3>{{ $t('trainingStats.averageParticipationYear') }}</h3>
<div class="stat-number">{{ averageParticipationYear.toFixed(1) }}</div>
<h3>Nicht im Training</h3>
<div class="stat-number">{{ filteredMembers.filter((member) => member.notInTraining).length }}</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>
<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>
</div>
</div>
</section>
<section class="stats-panel">
<div class="panel-header">
<h3>Anwesenheit nach Altersklasse</h3>
<span>{{ ageGroupStats.length }} Klassen</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>
</div>
</section>
</div>
<!-- Trainingstage-Tabelle (standardmäßig aufgeklappt) -->
<div class="collapsible-section">
<div class="section-header" @click="toggleTrainingDays">
@@ -48,7 +176,7 @@
</tr>
</thead>
<tbody>
<tr v-for="day in trainingDays" :key="day.id">
<tr v-for="day in filteredTrainingDays" :key="day.id">
<td>{{ formatDate(day.date) }}</td>
<td>{{ getWeekday(day.date) }}</td>
<td>{{ day.participantCount }}</td>
@@ -143,6 +271,8 @@ import { mapGetters } from 'vuex';
import apiClient from '../apiClient';
import TrainingDetailsDialog from '../components/TrainingDetailsDialog.vue';
const WEEKDAY_NAMES = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
export default {
name: 'TrainingStatsView',
components: {
@@ -151,90 +281,10 @@ export default {
computed: {
...mapGetters(['isAuthenticated', 'currentClub']),
averageParticipation12Months() {
if (this.trainingsCount12Months === 0) return 0;
const total = this.activeMembers.reduce((sum, member) => sum + member.participation12Months, 0);
return total / this.trainingsCount12Months;
},
averageParticipation3Months() {
if (this.trainingsCount3Months === 0) return 0;
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 [];
if (!this.filteredMembers.length) return [];
return [...this.activeMembers].sort((a, b) => {
return [...this.filteredMembers].sort((a, b) => {
let aValue, bValue;
if (this.sortField === 'name') {
@@ -255,6 +305,190 @@ export default {
}
}
});
},
weekdayOptions() {
return WEEKDAY_NAMES.map((label, index) => ({ value: String(index), label }));
},
trainingGroupOptions() {
const groups = new Map();
this.activeMembers.forEach((member) => {
(member.trainingGroups || []).forEach((group) => {
if (!groups.has(String(group.id))) {
groups.set(String(group.id), { value: String(group.id), label: group.name });
}
});
});
return Array.from(groups.values()).sort((a, b) => a.label.localeCompare(b.label, 'de'));
},
filteredMembers() {
if (this.selectedTrainingGroup === 'all') {
return this.activeMembers;
}
return this.activeMembers.filter((member) =>
(member.trainingGroups || []).some((group) => String(group.id) === this.selectedTrainingGroup)
);
},
filteredTrainingDays() {
if (this.selectedWeekday === 'all') {
return this.trainingDays;
}
return this.trainingDays.filter((day) => String(new Date(day.date).getDay()) === this.selectedWeekday);
},
filteredOverview() {
const totalParticipants = this.filteredTrainingDays.reduce((sum, day) => sum + (day.participantCount || 0), 0);
const averageParticipants = this.filteredTrainingDays.length > 0 ? totalParticipants / this.filteredTrainingDays.length : 0;
const denominatorMembers = this.filteredMembers.length || 0;
const attendanceRate = denominatorMembers > 0 && this.filteredTrainingDays.length > 0
? (totalParticipants / (denominatorMembers * this.filteredTrainingDays.length)) * 100
: 0;
const bestTrainingDay = this.filteredTrainingDays.reduce((best, day) => {
if (!best || (day.participantCount || 0) > (best.participantCount || 0)) {
return day;
}
return best;
}, null);
return {
totalParticipants,
averageParticipants,
attendanceRate,
bestTrainingDay
};
},
filteredMonthlyTrend() {
const months = new Map();
this.filteredTrainingDays.forEach((day) => {
const date = new Date(day.date);
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
if (!months.has(key)) {
months.set(key, {
key,
label: `${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`,
trainingCount: 0,
participantCount: 0,
averageParticipants: 0
});
}
const entry = months.get(key);
entry.trainingCount += 1;
entry.participantCount += day.participantCount || 0;
});
return Array.from(months.values())
.sort((a, b) => a.key.localeCompare(b.key))
.map((entry) => ({
...entry,
averageParticipants: entry.trainingCount > 0 ? entry.participantCount / entry.trainingCount : 0
}));
},
filteredWeekdayStats() {
const buckets = new Map();
this.filteredTrainingDays.forEach((day) => {
const date = new Date(day.date);
const weekdayIndex = date.getDay();
const key = String(weekdayIndex);
if (!buckets.has(key)) {
buckets.set(key, {
weekday: WEEKDAY_NAMES[weekdayIndex],
weekdayIndex,
trainingCount: 0,
participantCount: 0,
averageParticipants: 0
});
}
const entry = buckets.get(key);
entry.trainingCount += 1;
entry.participantCount += day.participantCount || 0;
});
return Array.from(buckets.values())
.sort((a, b) => a.weekdayIndex - b.weekdayIndex)
.map((entry) => ({
...entry,
averageParticipants: entry.trainingCount > 0 ? entry.participantCount / entry.trainingCount : 0
}));
},
filteredMemberDistribution() {
return {
highlyActive: this.filteredMembers.filter((member) => (member.participationRate12Months || 0) >= 0.75).length,
regular: this.filteredMembers.filter((member) => (member.participationRate12Months || 0) >= 0.4 && (member.participationRate12Months || 0) < 0.75).length,
occasional: this.filteredMembers.filter((member) => (member.participationRate12Months || 0) > 0 && (member.participationRate12Months || 0) < 0.4).length,
inactive: this.filteredMembers.filter((member) => (member.participation12Months || 0) === 0).length
};
},
groupPerformance() {
const groups = new Map();
this.filteredMembers.forEach((member) => {
const memberGroups = member.trainingGroups || [];
if (memberGroups.length === 0) {
if (!groups.has('ohne-gruppe')) {
groups.set('ohne-gruppe', { name: 'Ohne Trainingsgruppe', memberCount: 0, totalParticipations12Months: 0, participationRateSum: 0 });
}
const entry = groups.get('ohne-gruppe');
entry.memberCount += 1;
entry.totalParticipations12Months += member.participation12Months || 0;
entry.participationRateSum += member.participationRate12Months || 0;
return;
}
memberGroups.forEach((group) => {
const key = String(group.id);
if (!groups.has(key)) {
groups.set(key, { name: group.name, memberCount: 0, totalParticipations12Months: 0, participationRateSum: 0 });
}
const entry = groups.get(key);
entry.memberCount += 1;
entry.totalParticipations12Months += member.participation12Months || 0;
entry.participationRateSum += member.participationRate12Months || 0;
});
});
return Array.from(groups.values())
.map((entry) => ({
...entry,
averageParticipations12Months: entry.memberCount > 0 ? entry.totalParticipations12Months / entry.memberCount : 0,
participationRate: entry.memberCount > 0 ? (entry.participationRateSum / entry.memberCount) * 100 : 0
}))
.sort((a, b) => b.averageParticipations12Months - a.averageParticipations12Months);
},
ageGroupStats() {
const buckets = [
{ label: 'Kinder U13', memberCount: 0, totalParticipations12Months: 0, match: (age) => age !== null && age <= 12 },
{ label: 'Jugend U19', memberCount: 0, totalParticipations12Months: 0, match: (age) => age !== null && age >= 13 && age <= 18 },
{ label: 'Erwachsene', memberCount: 0, totalParticipations12Months: 0, match: (age) => age !== null && age >= 19 && age <= 59 },
{ label: 'Senioren 60+', memberCount: 0, totalParticipations12Months: 0, match: (age) => age !== null && age >= 60 },
{ label: 'Ohne Geburtsdatum', memberCount: 0, totalParticipations12Months: 0, match: (age) => age === null }
];
this.filteredMembers.forEach((member) => {
const age = this.getAge(member.birthDate);
const bucket = buckets.find((entry) => entry.match(age));
if (!bucket) return;
bucket.memberCount += 1;
bucket.totalParticipations12Months += member.participation12Months || 0;
});
return buckets
.filter((entry) => entry.memberCount > 0)
.map((entry) => ({
...entry,
averageParticipations12Months: entry.memberCount > 0 ? entry.totalParticipations12Months / entry.memberCount : 0
}));
}
},
@@ -264,6 +498,24 @@ export default {
trainingsCount12Months: 0,
trainingsCount3Months: 0,
trainingDays: [],
overview: {
activeMembersCount: 0,
totalParticipants12Months: 0,
averageParticipants12Months: 0,
attendanceRate12Months: 0,
inactiveMembersCount: 0,
bestTrainingDay: null
},
weekdayStats: [],
monthlyTrend: [],
memberDistribution: {
highlyActive: 0,
regular: 0,
occasional: 0,
inactive: 0
},
selectedWeekday: 'all',
selectedTrainingGroup: 'all',
showDetailsModal: false,
selectedMember: {},
loading: false,
@@ -302,42 +554,16 @@ export default {
this.trainingsCount12Months = response.data.trainingsCount12Months || 0;
this.trainingsCount3Months = response.data.trainingsCount3Months || 0;
this.trainingDays = response.data.trainingDays || [];
this.overview = response.data.overview || this.overview;
this.weekdayStats = response.data.weekdayStats || [];
this.monthlyTrend = response.data.monthlyTrend || [];
this.memberDistribution = response.data.memberDistribution || this.memberDistribution;
} catch (error) {
// Kein Alert - es ist normal, dass nicht alle Daten verfügbar sind
} finally {
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;
@@ -379,6 +605,19 @@ export default {
const date = new Date(dateString);
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
},
getAge(birthDate) {
if (!birthDate) return null;
const today = new Date();
const birth = new Date(birthDate);
if (Number.isNaN(birth.getTime())) return null;
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
},
sortBy(field) {
if (this.sortField === field) {
@@ -396,6 +635,11 @@ export default {
return '↕️'; // Neutral
}
return this.sortDirection === 'asc' ? '↑' : '↓';
},
getMonthBarWidth(value) {
const maxValue = Math.max(...this.filteredMonthlyTrend.map((entry) => entry.averageParticipants || 0), 1);
return (value / maxValue) * 100;
}
}
};
@@ -414,7 +658,7 @@ export default {
.stats-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.8rem;
margin-bottom: 1.5rem;
}
@@ -442,6 +686,150 @@ export default {
color: var(--primary-color);
}
.stats-panels-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stats-panel {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-large);
box-shadow: var(--shadow-light);
padding: 1.1rem 1.15rem;
}
.stats-panel-highlight {
background: linear-gradient(135deg, rgba(47, 122, 95, 0.08), rgba(255, 255, 255, 0.98));
}
.panel-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.9rem;
}
.panel-header h3 {
margin: 0;
font-size: 1rem;
color: var(--text-primary);
}
.panel-header span {
color: var(--text-secondary);
font-size: 0.85rem;
}
.trend-list {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.trend-row {
display: grid;
grid-template-columns: minmax(92px, 120px) minmax(0, 1fr) 52px;
gap: 0.75rem;
align-items: center;
}
.trend-meta {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.trend-meta strong {
font-size: 0.92rem;
color: var(--text-primary);
}
.trend-meta span {
font-size: 0.78rem;
color: var(--text-secondary);
}
.trend-bar-track {
height: 0.7rem;
border-radius: 999px;
background: var(--bg-light);
overflow: hidden;
}
.trend-bar-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--primary-color), var(--secondary-color));
}
.trend-value {
text-align: right;
font-weight: 700;
color: var(--primary-color);
}
.weekday-grid,
.distribution-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.weekday-card,
.distribution-card {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.85rem 0.9rem;
border-radius: 14px;
background: var(--bg-light);
border: 1px solid var(--border-color);
}
.weekday-card strong,
.distribution-card strong {
color: var(--text-primary);
}
.weekday-card span,
.distribution-card small {
color: var(--text-secondary);
}
.distribution-card span {
font-size: 1.35rem;
font-weight: 700;
color: var(--primary-color);
}
.highlight-block {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
}
.highlight-block strong {
font-size: 1.05rem;
color: var(--text-primary);
}
.highlight-block span,
.highlight-block small {
color: var(--text-secondary);
}
.highlight-number {
font-size: 2.2rem;
line-height: 1;
font-weight: 800;
color: var(--primary-color);
}
.collapsible-section {
background: white;
border-radius: var(--border-radius-large);
@@ -713,6 +1101,20 @@ export default {
.stats-summary {
grid-template-columns: 1fr;
}
.stats-panels-grid,
.weekday-grid,
.distribution-grid {
grid-template-columns: 1fr;
}
.trend-row {
grid-template-columns: 1fr;
}
.trend-value {
text-align: left;
}
.members-table {
font-size: 0.875rem;