feat(TrainingStats): enhance training statistics view and participant details

- Updated TrainingStatsService to include member details (first name, last name) in participant data.
- Modified TrainingDetailsDialog to remove unnecessary time display for training sessions.
- Added new filters for training days in TrainingStatsView, allowing users to select specific training days and view attending members.
- Enhanced localization files to support new training day filter and participant-related strings across multiple languages.
This commit is contained in:
Torsten Schulz (local)
2026-03-28 13:35:34 +01:00
parent 0df8674353
commit cb7830571b
19 changed files with 372 additions and 23 deletions

View File

@@ -41,7 +41,6 @@
>
<div class="training-date">{{ formatDate(training.date) }}</div>
<div class="training-activity">{{ training.activityName }}</div>
<div class="training-time">{{ training.startTime }} - {{ training.endTime }}</div>
</div>
</div>
<div v-if="!member.trainingDetails || member.trainingDetails.length === 0" class="no-trainings">
@@ -188,11 +187,6 @@ export default {
flex: 1;
}
.training-time {
color: var(--text-muted);
font-size: 0.875rem;
}
.no-trainings {
padding: 2rem;
text-align: center;
@@ -219,4 +213,3 @@ export default {
}
}
</style>

View File

@@ -795,10 +795,14 @@
"averageParticipationHalfYear": "Durchschnittliche Teilnahme (Halbjahr)",
"averageParticipationYear": "Durchschnittliche Teilnahme (Jahr)",
"trainingDays": "Trainingstage (letzte 12 Monate)",
"trainingDayFilter": "Trainingstag",
"allTrainingDays": "Alle Trainingstage",
"memberParticipations": "Mitglieder-Teilnahmen",
"date": "Datum",
"weekday": "Wochentag",
"participants": "Teilnehmer",
"attendingMembers": "Anwesende Mitglieder",
"noParticipants": "Keine Teilnehmer",
"name": "Name",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -1098,10 +1098,14 @@
"averageParticipationHalfYear": "Durchschnittliche Teilnahme (Halbjahr)",
"averageParticipationYear": "Durchschnittliche Teilnahme (Jahr)",
"trainingDays": "Trainingstage (letzte 12 Monate)",
"trainingDayFilter": "Trainingstag",
"allTrainingDays": "Alle Trainingstage",
"memberParticipations": "Mitglieder-Teilnahmen",
"date": "Datum",
"weekday": "Wochentag",
"participants": "Teilnehmer",
"attendingMembers": "Anwesende Mitglieder",
"noParticipants": "Keine Teilnehmer",
"name": "Name",
"ttr": "TTR",
"qttr": "QTTR",
@@ -1318,6 +1322,7 @@
"clearFields": "Felder leeren",
"playerStats": "Spieleinsätze",
"playerStatsIntro": "Schneller Überblick über Einsätze der Mannschaft in dieser Saison.",
"lineupProposal": "Mannschaftsmeldung nach QTTR",
"refreshStats": "Aktualisieren",
"loadingStats": "Lade Statistiken...",
"noPlayerStats": "Keine Spieleinsätze erfasst.",

View File

@@ -795,10 +795,14 @@
"averageParticipationHalfYear": "Average participation (half-year)",
"averageParticipationYear": "Average participation (year)",
"trainingDays": "Training days (last 12 months)",
"trainingDayFilter": "Training day",
"allTrainingDays": "All training days",
"memberParticipations": "Member participations",
"date": "Date",
"weekday": "Weekday",
"participants": "Participants",
"attendingMembers": "Attending members",
"noParticipants": "No participants",
"name": "Name",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -911,10 +911,14 @@
"averageParticipationHalfYear": "Average participation (half-year)",
"averageParticipationYear": "Average participation (year)",
"trainingDays": "Training days (last 12 months)",
"trainingDayFilter": "Training day",
"allTrainingDays": "All training days",
"memberParticipations": "Member participations",
"date": "Date",
"weekday": "Weekday",
"participants": "Participants",
"attendingMembers": "Attending members",
"noParticipants": "No participants",
"name": "Name",
"ttr": "TTR",
"qttr": "QTTR",
@@ -1009,6 +1013,7 @@
"clearFields": "Clear fields",
"playerStats": "Appearances",
"playerStatsIntro": "Quick overview of this team's appearances in the current season.",
"lineupProposal": "Line-up by QTTR",
"refreshStats": "Refresh",
"loadingStats": "Loading statistics...",
"noPlayerStats": "No appearances recorded.",

View File

@@ -795,10 +795,14 @@
"averageParticipationHalfYear": "Average participation (half-year)",
"averageParticipationYear": "Average participation (year)",
"trainingDays": "Training days (last 12 months)",
"trainingDayFilter": "Training day",
"allTrainingDays": "All training days",
"memberParticipations": "Member participations",
"date": "Date",
"weekday": "Weekday",
"participants": "Participants",
"attendingMembers": "Attending members",
"noParticipants": "No participants",
"name": "Name",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "Participación media (semestre)",
"averageParticipationYear": "Participación media (año)",
"trainingDays": "Días de entrenamiento (últimos 12 meses)",
"trainingDayFilter": "Día de entrenamiento",
"allTrainingDays": "Todos los días de entrenamiento",
"memberParticipations": "Participaciones de los miembros",
"date": "Fecha",
"weekday": "Día de la semana",
"participants": "Participantes",
"attendingMembers": "Miembros presentes",
"noParticipants": "Sin participantes",
"name": "Nombre",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "Karaniwang paglahok (kalahating taon)",
"averageParticipationYear": "Karaniwang paglahok (taon)",
"trainingDays": "Mga araw ng pagsasanay (huling 12 buwan)",
"trainingDayFilter": "Araw ng pagsasanay",
"allTrainingDays": "Lahat ng araw ng pagsasanay",
"memberParticipations": "Paglahok ng miyembro",
"date": "Petsa",
"weekday": "Araw ng linggo",
"participants": "Mga kalahok",
"attendingMembers": "Mga dumalong miyembro",
"noParticipants": "Walang kalahok",
"name": "Pangalan",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "Participation moyenne (semestre)",
"averageParticipationYear": "Participation moyenne (année)",
"trainingDays": "Jours d'entraînement (12 derniers mois)",
"trainingDayFilter": "Jour d'entraînement",
"allTrainingDays": "Tous les jours d'entraînement",
"memberParticipations": "Participations des membres",
"date": "Date",
"weekday": "Jour de semaine",
"participants": "Participants",
"attendingMembers": "Membres présents",
"noParticipants": "Aucun participant",
"name": "Nom",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "Partecipazione media (semestre)",
"averageParticipationYear": "Partecipazione media (anno)",
"trainingDays": "Giorni di allenamento (ultimi 12 mesi)",
"trainingDayFilter": "Giorno di allenamento",
"allTrainingDays": "Tutti i giorni di allenamento",
"memberParticipations": "Partecipazioni dei membri",
"date": "Data",
"weekday": "Giorno della settimana",
"participants": "Partecipanti",
"attendingMembers": "Membri presenti",
"noParticipants": "Nessun partecipante",
"name": "Nome",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "平均参加数(半年)",
"averageParticipationYear": "平均参加数(年間)",
"trainingDays": "練習日(過去 12 か月)",
"trainingDayFilter": "練習日",
"allTrainingDays": "すべての練習日",
"memberParticipations": "メンバー参加数",
"date": "日付",
"weekday": "曜日",
"participants": "参加者",
"attendingMembers": "参加した会員",
"noParticipants": "参加者なし",
"name": "名前",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "Średnia frekwencja (półrocze)",
"averageParticipationYear": "Średnia frekwencja (rok)",
"trainingDays": "Dni treningowe (ostatnie 12 miesięcy)",
"trainingDayFilter": "Dzień treningowy",
"allTrainingDays": "Wszystkie dni treningowe",
"memberParticipations": "Udziały członków",
"date": "Data",
"weekday": "Dzień tygodnia",
"participants": "Uczestnicy",
"attendingMembers": "Obecni członkowie",
"noParticipants": "Brak uczestników",
"name": "Nazwa",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "การเข้าร่วมเฉลี่ย (ครึ่งปี)",
"averageParticipationYear": "การเข้าร่วมเฉลี่ย (ปี)",
"trainingDays": "วันฝึกซ้อม (12 เดือนล่าสุด)",
"trainingDayFilter": "วันฝึกซ้อม",
"allTrainingDays": "วันฝึกซ้อมทั้งหมด",
"memberParticipations": "การเข้าร่วมของสมาชิก",
"date": "วันที่",
"weekday": "วันในสัปดาห์",
"participants": "ผู้เข้าร่วม",
"attendingMembers": "สมาชิกที่เข้าร่วม",
"noParticipants": "ไม่มีผู้เข้าร่วม",
"name": "ชื่อ",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "Karaniwang paglahok (kalahating taon)",
"averageParticipationYear": "Karaniwang paglahok (taon)",
"trainingDays": "Mga araw ng pagsasanay (huling 12 buwan)",
"trainingDayFilter": "Araw ng pagsasanay",
"allTrainingDays": "Lahat ng araw ng pagsasanay",
"memberParticipations": "Paglahok ng miyembro",
"date": "Petsa",
"weekday": "Araw ng linggo",
"participants": "Mga kalahok",
"attendingMembers": "Mga dumalong miyembro",
"noParticipants": "Walang kalahok",
"name": "Pangalan",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "平均参与人数(半年)",
"averageParticipationYear": "平均参与人数(全年)",
"trainingDays": "训练日(近 12 个月)",
"trainingDayFilter": "训练日",
"allTrainingDays": "所有训练日",
"memberParticipations": "成员参与次数",
"date": "日期",
"weekday": "星期",
"participants": "参与者",
"attendingMembers": "出席成员",
"noParticipants": "无参与者",
"name": "姓名",
"ttr": "TTR",
"qttr": "QTTR",

View File

@@ -646,8 +646,6 @@ export default {
const allMembers = response.data;
// Filter members by age class if league has age class info
// For now, show all active members
const activeMembers = allMembers.filter(m => m.active);
this.playerSelectionDialog.members = activeMembers.map(m => ({

View File

@@ -183,6 +183,37 @@
</tr>
</tbody>
</table>
<div v-if="lineupProposalGroups.length" class="lineup-proposal-card">
<div class="lineup-proposal-header">
<strong>{{ t('teamManagement.lineupProposal') }}</strong>
<span>{{ lineupProposalMemberCount }}</span>
</div>
<div class="lineup-proposal-groups">
<section v-for="group in lineupProposalGroups" :key="group.code" class="lineup-proposal-group">
<div class="lineup-proposal-group-head">
<strong>{{ group.label }}</strong>
<span>{{ group.members.length }}</span>
</div>
<table class="lineup-proposal-table">
<thead>
<tr>
<th>#</th>
<th>{{ t('teamManagement.player') }}</th>
<th :title="t('teamManagement.qttr')">(Q)TTR</th>
</tr>
</thead>
<tbody>
<tr v-for="(member, index) in group.members" :key="member.id">
<td class="lineup-rank">{{ index + 1 }}</td>
<td>{{ member.firstName }} {{ member.lastName }}</td>
<td class="lineup-rating">{{ member.lineupRatingLabel }}</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
<div v-if="teamToEdit && activeEditorSection === 'documents'" class="workspace-section-panel advanced-settings">
@@ -461,6 +492,7 @@ export default {
const playerStats = ref([]);
const loadingStats = ref(false);
const memberById = ref({});
const clubMembers = ref([]);
// Scheduler Jobs Info
const schedulerJobs = ref({
@@ -502,6 +534,108 @@ export default {
return true;
});
});
const parseLeagueAgeGroupCode = (leagueName) => {
const source = String(leagueName || '');
const compactMatch = source.match(/([JM])(\d{1,2})/i);
if (compactMatch) {
return `J${compactMatch[2]}`;
}
const youthMatch = source.match(/(?:jugend|mädchen)\s*(\d{1,2})/i);
if (youthMatch) {
return `J${youthMatch[1]}`;
}
return 'adult';
};
const getMemberAgeGroupCode = (member) => {
if (!member?.birthDate) {
return 'unknown';
}
const birthDate = new Date(member.birthDate);
if (Number.isNaN(birthDate.getTime())) {
return 'unknown';
}
const ageByBirthYear = new Date().getFullYear() - birthDate.getFullYear();
if (ageByBirthYear <= 11) return 'J11';
if (ageByBirthYear <= 13) return 'J13';
if (ageByBirthYear <= 15) return 'J15';
if (ageByBirthYear <= 17) return 'J17';
if (ageByBirthYear <= 19) return 'J19';
return 'adult';
};
const getMemberAgeGroupLabel = (code) => {
if (code === 'adult') return t('members.adults');
if (code === 'unknown') return t('unknown');
return code;
};
const getMemberLineupRatingValue = (member) => {
const qttr = Number(member?.qttr);
if (Number.isFinite(qttr)) return qttr;
const ttr = Number(member?.ttr);
if (Number.isFinite(ttr)) return ttr;
return Number.NEGATIVE_INFINITY;
};
const getMemberLineupRatingLabel = (member) => {
const rating = getMemberLineupRatingValue(member);
return Number.isFinite(rating) ? String(rating) : '';
};
const lineupProposalGroups = computed(() => {
const members = (clubMembers.value || []).filter((member) => member?.active);
if (!members.length) {
return [];
}
const preferredAgeGroup = parseLeagueAgeGroupCode(teamToEdit.value?.league?.name);
const defaultOrder = ['adult', 'J19', 'J17', 'J15', 'J13', 'J11', 'unknown'];
const groupOrder = defaultOrder.includes(preferredAgeGroup)
? [preferredAgeGroup, ...defaultOrder.filter((entry) => entry !== preferredAgeGroup)]
: defaultOrder;
const groups = new Map();
members.forEach((member) => {
const code = getMemberAgeGroupCode(member);
if (!groups.has(code)) {
groups.set(code, {
code,
label: getMemberAgeGroupLabel(code),
members: []
});
}
groups.get(code).members.push({
...member,
lineupRatingLabel: getMemberLineupRatingLabel(member)
});
});
return Array.from(groups.values())
.map((group) => ({
...group,
members: [...group.members].sort((a, b) => {
const ratingDiff = getMemberLineupRatingValue(b) - getMemberLineupRatingValue(a);
if (ratingDiff !== 0) return ratingDiff;
const lastNameDiff = String(a.lastName || '').localeCompare(String(b.lastName || ''), 'de', { sensitivity: 'base' });
if (lastNameDiff !== 0) return lastNameDiff;
return String(a.firstName || '').localeCompare(String(b.firstName || ''), 'de', { sensitivity: 'base' });
})
}))
.sort((a, b) => {
const indexA = groupOrder.indexOf(a.code);
const indexB = groupOrder.indexOf(b.code);
const safeA = indexA >= 0 ? indexA : groupOrder.length;
const safeB = indexB >= 0 ? indexB : groupOrder.length;
return safeA - safeB;
});
});
const lineupProposalMemberCount = computed(() => lineupProposalGroups.value.reduce((sum, group) => sum + group.members.length, 0));
// Methods
const toggleNewTeam = () => {
@@ -1240,11 +1374,13 @@ export default {
try {
const membersResp = await apiClient.get(`/clubmembers/get/${selectedClub.value}/true`);
const map = {};
for (const m of membersResp.data || []) {
clubMembers.value = membersResp.data || [];
for (const m of clubMembers.value) {
map[m.id] = { ttr: m.ttr ?? null, qttr: m.qttr ?? null };
}
memberById.value = map;
} catch (e) {
clubMembers.value = [];
memberById.value = {};
}
@@ -1450,6 +1586,8 @@ export default {
teamsWithoutLeagueCount,
totalSeasonAppearances,
totalHalfAppearances,
lineupProposalGroups,
lineupProposalMemberCount,
toggleNewTeam,
resetToNewTeam,
resetNewTeam,
@@ -2798,6 +2936,64 @@ export default {
color: var(--primary-color);
}
.lineup-proposal-card {
margin-top: 1rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: #fff;
}
.lineup-proposal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.lineup-proposal-groups {
display: grid;
gap: 0.9rem;
padding: 1rem;
}
.lineup-proposal-group {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
overflow: hidden;
}
.lineup-proposal-group-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.7rem 0.9rem;
background: rgba(47, 122, 95, 0.08);
color: var(--primary-color);
}
.lineup-proposal-table {
width: 100%;
border-collapse: collapse;
}
.lineup-proposal-table th,
.lineup-proposal-table td {
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.lineup-proposal-table tbody tr:last-child td {
border-bottom: none;
}
.lineup-rank,
.lineup-rating {
width: 90px;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* Legacy styles (can be removed if not used elsewhere) */
.mytischtennis-header {
display: flex;

View File

@@ -10,6 +10,13 @@
<option v-for="option in weekdayOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</label>
<label class="stats-filter">
<span>{{ $t('trainingStats.trainingDayFilter') }}</span>
<select v-model="selectedTrainingDay">
<option value="all">{{ $t('trainingStats.allTrainingDays') }}</option>
<option v-for="option in trainingDayOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</label>
<label class="stats-filter">
<span>Trainingsgruppe</span>
<select v-model="selectedTrainingGroup">
@@ -173,6 +180,7 @@
<th>{{ $t('trainingStats.date') }}</th>
<th>{{ $t('trainingStats.weekday') }}</th>
<th>{{ $t('trainingStats.participants') }}</th>
<th>{{ $t('trainingStats.attendingMembers') }}</th>
</tr>
</thead>
<tbody>
@@ -180,6 +188,18 @@
<td>{{ formatDate(day.date) }}</td>
<td>{{ getWeekday(day.date) }}</td>
<td>{{ day.participantCount }}</td>
<td>
<div v-if="day.participants && day.participants.length" class="training-day-members">
<span
v-for="participant in day.participants"
:key="participant.id"
class="training-day-member-chip"
>
{{ participant.firstName }} {{ participant.lastName }}
</span>
</div>
<span v-else class="training-day-empty">{{ $t('trainingStats.noParticipants') }}</span>
</td>
</tr>
</tbody>
</table>
@@ -323,17 +343,7 @@ export default {
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() {
trainingDaysByWeekday() {
if (this.selectedWeekday === 'all') {
return this.trainingDays;
}
@@ -341,6 +351,50 @@ export default {
return this.trainingDays.filter((day) => String(new Date(day.date).getDay()) === this.selectedWeekday);
},
trainingDayOptions() {
return this.trainingDaysByWeekday.map((day) => ({
value: String(day.id),
label: `${this.formatDate(day.date)} (${this.getWeekday(day.date)})`,
}));
},
selectedTrainingDayParticipantIds() {
if (this.selectedTrainingDay === 'all') {
return null;
}
const selectedDay = this.trainingDays.find((day) => String(day.id) === String(this.selectedTrainingDay));
if (!selectedDay || !Array.isArray(selectedDay.participants)) {
return new Set();
}
return new Set(selectedDay.participants.map((participant) => Number(participant.id)).filter((id) => Number.isFinite(id)));
},
filteredMembers() {
let members = this.activeMembers;
if (this.selectedTrainingGroup !== 'all') {
members = members.filter((member) =>
(member.trainingGroups || []).some((group) => String(group.id) === this.selectedTrainingGroup)
);
}
if (this.selectedTrainingDayParticipantIds) {
members = members.filter((member) => this.selectedTrainingDayParticipantIds.has(Number(member.id)));
}
return members;
},
filteredTrainingDays() {
if (this.selectedTrainingDay === 'all') {
return this.trainingDaysByWeekday;
}
return this.trainingDaysByWeekday.filter((day) => String(day.id) === String(this.selectedTrainingDay));
},
filteredOverview() {
const totalParticipants = this.filteredTrainingDays.reduce((sum, day) => sum + (day.participantCount || 0), 0);
const averageParticipants = this.filteredTrainingDays.length > 0 ? totalParticipants / this.filteredTrainingDays.length : 0;
@@ -515,6 +569,7 @@ export default {
inactive: 0
},
selectedWeekday: 'all',
selectedTrainingDay: 'all',
selectedTrainingGroup: 'all',
showDetailsModal: false,
selectedMember: {},
@@ -540,6 +595,13 @@ export default {
}
},
immediate: true
},
selectedWeekday() {
if (this.selectedTrainingDay === 'all') return;
const hasSelectedDay = this.trainingDaysByWeekday.some((day) => String(day.id) === String(this.selectedTrainingDay));
if (!hasSelectedDay) {
this.selectedTrainingDay = 'all';
}
}
},
@@ -939,6 +1001,28 @@ export default {
border-collapse: collapse;
}
.training-day-members {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.training-day-member-chip {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: rgba(26, 130, 172, 0.12);
color: var(--text-color);
font-size: 0.8rem;
line-height: 1.2;
}
.training-day-empty {
color: var(--text-muted);
font-size: 0.9rem;
}
.members-table th {
background: var(--bg-light);
padding: 1rem;