diff --git a/backend/services/trainingStatsService.js b/backend/services/trainingStatsService.js
index 91dc946d..e9f7b962 100644
--- a/backend/services/trainingStatsService.js
+++ b/backend/services/trainingStatsService.js
@@ -205,7 +205,12 @@ class TrainingStatsService {
include: [{
model: Participant,
as: 'participantList',
- attributes: ['id', 'attendanceStatus']
+ attributes: ['id', 'memberId', 'attendanceStatus'],
+ include: [{
+ model: Member,
+ as: 'member',
+ attributes: ['id', 'firstName', 'lastName']
+ }]
}],
order: [['date', 'DESC']]
});
@@ -215,7 +220,22 @@ class TrainingStatsService {
date: day.date,
participantCount: day.participantList
? day.participantList.filter((participant) => !participant.attendanceStatus || participant.attendanceStatus === 'present').length
- : 0
+ : 0,
+ participants: day.participantList
+ ? day.participantList
+ .filter((participant) => !participant.attendanceStatus || participant.attendanceStatus === 'present')
+ .map((participant) => ({
+ id: participant.member?.id || participant.memberId,
+ firstName: participant.member?.firstName || '',
+ lastName: participant.member?.lastName || '',
+ }))
+ .filter((participant) => Number.isFinite(Number(participant.id)))
+ .sort((a, b) => {
+ const lastCompare = String(a.lastName || '').localeCompare(String(b.lastName || ''), 'de', { sensitivity: 'base' });
+ if (lastCompare !== 0) return lastCompare;
+ return String(a.firstName || '').localeCompare(String(b.firstName || ''), 'de', { sensitivity: 'base' });
+ })
+ : []
}));
const totalParticipants12Months = formattedTrainingDays.reduce((sum, day) => sum + (day.participantCount || 0), 0);
diff --git a/frontend/src/components/TrainingDetailsDialog.vue b/frontend/src/components/TrainingDetailsDialog.vue
index 961de30a..951978ee 100644
--- a/frontend/src/components/TrainingDetailsDialog.vue
+++ b/frontend/src/components/TrainingDetailsDialog.vue
@@ -41,7 +41,6 @@
>
@@ -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 {
}
}
-
diff --git a/frontend/src/i18n/locales/de-CH.json b/frontend/src/i18n/locales/de-CH.json
index aac4f52a..5365c4fe 100644
--- a/frontend/src/i18n/locales/de-CH.json
+++ b/frontend/src/i18n/locales/de-CH.json
@@ -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",
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 75e6b62e..995d0f22 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -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.",
diff --git a/frontend/src/i18n/locales/en-AU.json b/frontend/src/i18n/locales/en-AU.json
index 11913930..62e2f0fc 100644
--- a/frontend/src/i18n/locales/en-AU.json
+++ b/frontend/src/i18n/locales/en-AU.json
@@ -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",
diff --git a/frontend/src/i18n/locales/en-GB.json b/frontend/src/i18n/locales/en-GB.json
index 4461905d..d555242c 100644
--- a/frontend/src/i18n/locales/en-GB.json
+++ b/frontend/src/i18n/locales/en-GB.json
@@ -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.",
diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json
index 1c80c928..926826bc 100644
--- a/frontend/src/i18n/locales/en-US.json
+++ b/frontend/src/i18n/locales/en-US.json
@@ -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",
diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json
index 24f19748..bdb6f4da 100644
--- a/frontend/src/i18n/locales/es.json
+++ b/frontend/src/i18n/locales/es.json
@@ -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",
diff --git a/frontend/src/i18n/locales/fil.json b/frontend/src/i18n/locales/fil.json
index 45a8f13e..9fb18ce0 100644
--- a/frontend/src/i18n/locales/fil.json
+++ b/frontend/src/i18n/locales/fil.json
@@ -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",
diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json
index 34fd84af..e872a7db 100644
--- a/frontend/src/i18n/locales/fr.json
+++ b/frontend/src/i18n/locales/fr.json
@@ -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",
diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json
index a226db02..8d2933fc 100644
--- a/frontend/src/i18n/locales/it.json
+++ b/frontend/src/i18n/locales/it.json
@@ -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",
diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json
index ab940cb9..c108e2ef 100644
--- a/frontend/src/i18n/locales/ja.json
+++ b/frontend/src/i18n/locales/ja.json
@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "平均参加数(半年)",
"averageParticipationYear": "平均参加数(年間)",
"trainingDays": "練習日(過去 12 か月)",
+ "trainingDayFilter": "練習日",
+ "allTrainingDays": "すべての練習日",
"memberParticipations": "メンバー参加数",
"date": "日付",
"weekday": "曜日",
"participants": "参加者",
+ "attendingMembers": "参加した会員",
+ "noParticipants": "参加者なし",
"name": "名前",
"ttr": "TTR",
"qttr": "QTTR",
diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json
index f0bc1848..8cd20f6c 100644
--- a/frontend/src/i18n/locales/pl.json
+++ b/frontend/src/i18n/locales/pl.json
@@ -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",
diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json
index 02544c2e..6fea9ac4 100644
--- a/frontend/src/i18n/locales/th.json
+++ b/frontend/src/i18n/locales/th.json
@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "การเข้าร่วมเฉลี่ย (ครึ่งปี)",
"averageParticipationYear": "การเข้าร่วมเฉลี่ย (ปี)",
"trainingDays": "วันฝึกซ้อม (12 เดือนล่าสุด)",
+ "trainingDayFilter": "วันฝึกซ้อม",
+ "allTrainingDays": "วันฝึกซ้อมทั้งหมด",
"memberParticipations": "การเข้าร่วมของสมาชิก",
"date": "วันที่",
"weekday": "วันในสัปดาห์",
"participants": "ผู้เข้าร่วม",
+ "attendingMembers": "สมาชิกที่เข้าร่วม",
+ "noParticipants": "ไม่มีผู้เข้าร่วม",
"name": "ชื่อ",
"ttr": "TTR",
"qttr": "QTTR",
diff --git a/frontend/src/i18n/locales/tl.json b/frontend/src/i18n/locales/tl.json
index 137b3d80..6609750b 100644
--- a/frontend/src/i18n/locales/tl.json
+++ b/frontend/src/i18n/locales/tl.json
@@ -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",
diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json
index 4a789fff..4ff678a1 100644
--- a/frontend/src/i18n/locales/zh.json
+++ b/frontend/src/i18n/locales/zh.json
@@ -765,10 +765,14 @@
"averageParticipationHalfYear": "平均参与人数(半年)",
"averageParticipationYear": "平均参与人数(全年)",
"trainingDays": "训练日(近 12 个月)",
+ "trainingDayFilter": "训练日",
+ "allTrainingDays": "所有训练日",
"memberParticipations": "成员参与次数",
"date": "日期",
"weekday": "星期",
"participants": "参与者",
+ "attendingMembers": "出席成员",
+ "noParticipants": "无参与者",
"name": "姓名",
"ttr": "TTR",
"qttr": "QTTR",
diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue
index 620a95f4..cc54f104 100644
--- a/frontend/src/views/ScheduleView.vue
+++ b/frontend/src/views/ScheduleView.vue
@@ -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 => ({
diff --git a/frontend/src/views/TeamManagementView.vue b/frontend/src/views/TeamManagementView.vue
index 776eb0b4..96779f6a 100644
--- a/frontend/src/views/TeamManagementView.vue
+++ b/frontend/src/views/TeamManagementView.vue
@@ -183,6 +183,37 @@
+
+
+
+
+
+
+ {{ group.label }}
+ {{ group.members.length }}
+
+
+
+
+ | # |
+ {{ t('teamManagement.player') }} |
+ (Q)TTR |
+
+
+
+
+ | {{ index + 1 }} |
+ {{ member.firstName }} {{ member.lastName }} |
+ {{ member.lineupRatingLabel }} |
+
+
+
+
+
+
@@ -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;
diff --git a/frontend/src/views/TrainingStatsView.vue b/frontend/src/views/TrainingStatsView.vue
index 4a89e62a..f13a9c77 100644
--- a/frontend/src/views/TrainingStatsView.vue
+++ b/frontend/src/views/TrainingStatsView.vue
@@ -10,6 +10,13 @@
+