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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user