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 @@ >
{{ formatDate(training.date) }}
{{ training.activityName }}
-
{{ training.startTime }} - {{ training.endTime }}
@@ -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 @@ + +
+
+ {{ t('teamManagement.lineupProposal') }} + {{ lineupProposalMemberCount }} +
+
+
+
+ {{ 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 @@ +