From 8b9a4b7bcae6e6617837e9592a5c93bb3fd7b3c9 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 1 Apr 2026 15:26:08 +0200 Subject: [PATCH] feat(MembersOverview): add season filter and enhance age group selection - Introduced a new season filter dropdown in the MembersOverviewSection for selecting the season start year. - Enhanced age group selection by organizing options into groups for better clarity and added new age categories. - Updated localization files to include new terms related to the season filter and age classifications across multiple languages. - Improved the overall layout and styling of the filter components for a better user experience. --- ...ult_release_reserve_approved_to_member.sql | 10 ++ .../members/MembersOverviewSection.vue | 45 +++++- frontend/src/i18n/locales/de-CH.json | 22 ++- frontend/src/i18n/locales/de.json | 22 ++- frontend/src/i18n/locales/en-AU.json | 22 ++- frontend/src/i18n/locales/en-GB.json | 22 ++- frontend/src/i18n/locales/en-US.json | 22 ++- frontend/src/i18n/locales/es.json | 22 ++- frontend/src/i18n/locales/fil.json | 22 ++- frontend/src/i18n/locales/fr.json | 22 ++- frontend/src/i18n/locales/it.json | 22 ++- frontend/src/i18n/locales/ja.json | 22 ++- frontend/src/i18n/locales/pl.json | 22 ++- frontend/src/i18n/locales/th.json | 22 ++- frontend/src/i18n/locales/tl.json | 22 ++- frontend/src/i18n/locales/zh.json | 22 ++- frontend/src/utils/ttAgeClass.js | 128 ++++++++++++++++++ frontend/src/views/MembersView.vue | 110 +++++++++------ 18 files changed, 500 insertions(+), 101 deletions(-) create mode 100644 backend/migrations/20260401_add_adult_release_reserve_approved_to_member.sql create mode 100644 frontend/src/utils/ttAgeClass.js diff --git a/backend/migrations/20260401_add_adult_release_reserve_approved_to_member.sql b/backend/migrations/20260401_add_adult_release_reserve_approved_to_member.sql new file mode 100644 index 00000000..c71c1f1f --- /dev/null +++ b/backend/migrations/20260401_add_adult_release_reserve_approved_to_member.sql @@ -0,0 +1,10 @@ +-- Jugend-Freigaben (Schema wie backend/models/Member.js) +-- Fehlende Spalten verursachen SequelizeDatabaseError ER_BAD_FIELD_ERROR bei getClubMembers. + +ALTER TABLE `member` + ADD COLUMN `adult_release_approved` TINYINT(1) NOT NULL DEFAULT 0 + COMMENT 'Jugendspieler mit Freigabe fuer Erwachsene' + AFTER `member_form_handed_over`, + ADD COLUMN `adult_reserve_approved` TINYINT(1) NOT NULL DEFAULT 0 + COMMENT 'Jugendspieler als Ersatz bei Erwachsenen zugelassen' + AFTER `adult_release_approved`; diff --git a/frontend/src/components/members/MembersOverviewSection.vue b/frontend/src/components/members/MembersOverviewSection.vue index 94149b47..9607a25e 100644 --- a/frontend/src/components/members/MembersOverviewSection.vue +++ b/frontend/src/components/members/MembersOverviewSection.vue @@ -59,16 +59,38 @@ {{ $t('members.showInactiveMembers') }} +
+ + +

{{ $t('members.ttStichtagHint') }}

+
@@ -199,6 +221,8 @@ export default { selectedMemberScope: { type: String, required: true }, showInactiveMembers: { type: Boolean, required: true }, selectedAgeGroup: { type: String, required: true }, + selectedSeasonStartYear: { type: Number, required: true }, + seasonFilterOptions: { type: Array, required: true }, selectedAgeFrom: { type: [String, Number], required: true }, selectedAgeTo: { type: [String, Number], required: true }, selectedGender: { type: String, required: true }, @@ -221,6 +245,7 @@ export default { 'update:selected-member-scope', 'update:show-inactive-members', 'update:selected-age-group', + 'update:selected-season-start-year', 'update:selected-age-from', 'update:selected-age-to', 'update:selected-gender', @@ -297,6 +322,14 @@ export default { gap: 0.35rem; } +.filter-hint { + font-size: 0.75rem; + color: var(--text-muted); + margin: 0; + max-width: 24rem; + line-height: 1.35; +} + .member-search-group { flex: 1 1 18rem; } diff --git a/frontend/src/i18n/locales/de-CH.json b/frontend/src/i18n/locales/de-CH.json index 9d36b0f2..c95c50bd 100644 --- a/frontend/src/i18n/locales/de-CH.json +++ b/frontend/src/i18n/locales/de-CH.json @@ -467,12 +467,26 @@ "clearFields": "Felder leeren", "showInactiveMembers": "Inaktive Mitglieder anzeigen", "ageGroup": "Altersklasse", + "ttSeasonFilter": "Saison (Stichtag)", + "ttSeasonCurrentTag": "aktuell", + "ttSeasonNextTag": "kommend", + "ttStichtagHint": "Stichtag 1.1. (DTTB). Jungen: nur J-Klassen. Mädchen: J und M möglich.", + "ttAgeClassCol": "AK (TT)", + "ttAdult": "Erwachsene (kein Jugend nach Stichtag)", + "ttFilterGroupJ": "Mädchen & Jungen (gemischt)", + "ttFilterGroupM": "Mädchen (nur weiblich)", "adults": "Erwachsene (18+)", - "j19": "J19 (19 und jünger)", + "j19": "J19", "j17": "J17 (17 und jünger)", - "j15": "J15 (15 und jünger)", - "j13": "J13 (13 und jünger)", - "j11": "J11 (11 und jünger)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "Filter zurücksetzen", "imageInternet": "Bild (Inet?)", "testMember": "Testm.", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index fcfa0ad4..81bbb8ac 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -242,12 +242,26 @@ "clearFields": "Felder leeren", "showInactiveMembers": "Inaktive Mitglieder anzeigen", "ageGroup": "Altersklasse", + "ttSeasonFilter": "Saison (Stichtag)", + "ttSeasonCurrentTag": "aktuell", + "ttSeasonNextTag": "kommend", + "ttStichtagHint": "Stichtag 1.1. (DTTB). Jungen: nur J-Klassen. Mädchen: J und M möglich.", + "ttAgeClassCol": "AK (TT)", + "ttAdult": "Erwachsene (kein Jugend nach Stichtag)", + "ttFilterGroupJ": "Mädchen & Jungen (gemischt)", + "ttFilterGroupM": "Mädchen (nur weiblich)", "adults": "Erwachsene (18+)", - "j19": "J19 (19 und jünger)", + "j19": "J19", "j17": "J17 (17 und jünger)", - "j15": "J15 (15 und jünger)", - "j13": "J13 (13 und jünger)", - "j11": "J11 (11 und jünger)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "Filter zurücksetzen", "imageInternet": "Bild (Inet?)", "testMember": "Testm.", diff --git a/frontend/src/i18n/locales/en-AU.json b/frontend/src/i18n/locales/en-AU.json index d9a179a7..8a9516ff 100644 --- a/frontend/src/i18n/locales/en-AU.json +++ b/frontend/src/i18n/locales/en-AU.json @@ -467,12 +467,26 @@ "clearFields": "Clear fields", "showInactiveMembers": "Show inactive members", "ageGroup": "Age group", + "ttSeasonFilter": "Season (cutoff)", + "ttSeasonCurrentTag": "current", + "ttSeasonNextTag": "upcoming", + "ttStichtagHint": "Cutoff 1 Jan (DTTB). Boys: J classes only. Girls: J and M.", + "ttAgeClassCol": "Age class (TT)", + "ttAdult": "Adults (not youth by cutoff)", + "ttFilterGroupJ": "Boys & girls (mixed)", + "ttFilterGroupM": "Girls only", "adults": "Adults (18+)", - "j19": "U19 (19 and younger)", + "j19": "U19", "j17": "U17 (17 and younger)", - "j15": "U15 (15 and younger)", - "j13": "U13 (13 and younger)", - "j11": "U11 (11 and younger)", + "j15": "U15", + "j13": "U13", + "j11": "U11", + "j9": "U9", + "m19": "G19", + "m15": "G15", + "m13": "G13", + "m11": "G11", + "m9": "G9", "clearFilters": "Reset filters", "imageInternet": "Image (web?)", "testMember": "Trial", diff --git a/frontend/src/i18n/locales/en-GB.json b/frontend/src/i18n/locales/en-GB.json index bb8f7370..9e37c380 100644 --- a/frontend/src/i18n/locales/en-GB.json +++ b/frontend/src/i18n/locales/en-GB.json @@ -742,12 +742,26 @@ "clearFields": "Clear fields", "showInactiveMembers": "Show inactive members", "ageGroup": "Age group", + "ttSeasonFilter": "Season (cutoff)", + "ttSeasonCurrentTag": "current", + "ttSeasonNextTag": "upcoming", + "ttStichtagHint": "Cutoff 1 Jan (DTTB). Boys: J classes only. Girls: J and M.", + "ttAgeClassCol": "Age class (TT)", + "ttAdult": "Adults (not youth by cutoff)", + "ttFilterGroupJ": "Boys & girls (mixed)", + "ttFilterGroupM": "Girls only", "adults": "Adults (18+)", - "j19": "U19 (19 and younger)", + "j19": "U19", "j17": "U17 (17 and younger)", - "j15": "U15 (15 and younger)", - "j13": "U13 (13 and younger)", - "j11": "U11 (11 and younger)", + "j15": "U15", + "j13": "U13", + "j11": "U11", + "j9": "U9", + "m19": "G19", + "m15": "G15", + "m13": "G13", + "m11": "G11", + "m9": "G9", "clearFilters": "Reset filters", "imageInternet": "Image (web?)", "testMember": "Trial", diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index bb2c7d93..e6892d01 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -467,12 +467,26 @@ "clearFields": "Clear fields", "showInactiveMembers": "Show inactive members", "ageGroup": "Age group", + "ttSeasonFilter": "Season (cutoff)", + "ttSeasonCurrentTag": "current", + "ttSeasonNextTag": "upcoming", + "ttStichtagHint": "Cutoff 1 Jan (DTTB). Boys: J classes only. Girls: J and M.", + "ttAgeClassCol": "Age class (TT)", + "ttAdult": "Adults (not youth by cutoff)", + "ttFilterGroupJ": "Boys & girls (mixed)", + "ttFilterGroupM": "Girls only", "adults": "Adults (18+)", - "j19": "U19 (19 and younger)", + "j19": "U19", "j17": "U17 (17 and younger)", - "j15": "U15 (15 and younger)", - "j13": "U13 (13 and younger)", - "j11": "U11 (11 and younger)", + "j15": "U15", + "j13": "U13", + "j11": "U11", + "j9": "U9", + "m19": "G19", + "m15": "G15", + "m13": "G13", + "m11": "G11", + "m9": "G9", "clearFilters": "Reset filters", "imageInternet": "Image (web?)", "testMember": "Trial", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index f2bfd6b9..a23dfaae 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -434,12 +434,26 @@ "clearFields": "Vaciar campos", "showInactiveMembers": "Mostrar miembros inactivos", "ageGroup": "Categoría de edad", + "ttSeasonFilter": "Temporada (corte)", + "ttSeasonCurrentTag": "actual", + "ttSeasonNextTag": "próxima", + "ttStichtagHint": "Corte el 1 de enero (DTTB). Niños: solo clases J. Niñas: J y M.", + "ttAgeClassCol": "Cat. edad (TT)", + "ttAdult": "Adultos (no juvenil según corte)", + "ttFilterGroupJ": "Niños y niñas (mixto)", + "ttFilterGroupM": "Solo niñas", "adults": "Adultos (20+)", - "j19": "U19 (19 años o menos)", + "j19": "J19", "j17": "U17 (17 años o menos)", - "j15": "U15 (15 años o menos)", - "j13": "U13 (13 años o menos)", - "j11": "U11 (11 años o menos)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "Restablecer filtros", "imageInternet": "Imagen (web?)", "testMember": "Prueba", diff --git a/frontend/src/i18n/locales/fil.json b/frontend/src/i18n/locales/fil.json index 395f6e7c..77dbfb40 100644 --- a/frontend/src/i18n/locales/fil.json +++ b/frontend/src/i18n/locales/fil.json @@ -434,12 +434,26 @@ "clearFields": "Burahin ang mga field", "showInactiveMembers": "Ipakita ang mga hindi aktibong miyembro", "ageGroup": "Pangkat ng edad", + "ttSeasonFilter": "Season (cutoff)", + "ttSeasonCurrentTag": "kasalukuyan", + "ttSeasonNextTag": "susunod", + "ttStichtagHint": "Cutoff Enero 1 (DTTB). Lalaki: J lang. Babae: J at M.", + "ttAgeClassCol": "Edad (TT)", + "ttAdult": "Adulto (hindi youth sa cutoff)", + "ttFilterGroupJ": "Babae at lalaki (halo)", + "ttFilterGroupM": "Babae lamang", "adults": "Adulto (20+)", - "j19": "J19 (19 pababa)", + "j19": "J19", "j17": "J17 (17 pababa)", - "j15": "J15 (15 pababa)", - "j13": "J13 (13 pababa)", - "j11": "J11 (11 pababa)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "I-reset ang mga filter", "imageInternet": "Larawan (net?)", "testMember": "Trial", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index baf9328e..6f3fd407 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -434,12 +434,26 @@ "clearFields": "Vider les champs", "showInactiveMembers": "Afficher les membres inactifs", "ageGroup": "Catégorie d'âge", + "ttSeasonFilter": "Saison (date limite)", + "ttSeasonCurrentTag": "actuelle", + "ttSeasonNextTag": "à venir", + "ttStichtagHint": "Date limite le 1er janv. (DTTB). Garçons : classes J uniquement. Filles : J et M.", + "ttAgeClassCol": "Classe d'âge (TT)", + "ttAdult": "Adultes (pas jeunes selon la date limite)", + "ttFilterGroupJ": "Filles et garçons (mixte)", + "ttFilterGroupM": "Filles uniquement", "adults": "Adultes (20+)", - "j19": "U19 (19 ans et moins)", + "j19": "J19", "j17": "U17 (17 ans et moins)", - "j15": "U15 (15 ans et moins)", - "j13": "U13 (13 ans et moins)", - "j11": "U11 (11 ans et moins)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "Réinitialiser les filtres", "imageInternet": "Image (web ?)", "testMember": "Essai", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 0482d6ec..8fba25f3 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -434,12 +434,26 @@ "clearFields": "Svuota campi", "showInactiveMembers": "Mostra membri inattivi", "ageGroup": "Fascia d’età", + "ttSeasonFilter": "Stagione (data di riferimento)", + "ttSeasonCurrentTag": "attuale", + "ttSeasonNextTag": "prossima", + "ttStichtagHint": "Data di riferimento 1° gennaio (DTTB). Ragazzi: solo classi J. Ragazze: J e M.", + "ttAgeClassCol": "Classe (TT)", + "ttAdult": "Adulti (non giovanili secondo la data)", + "ttFilterGroupJ": "Ragazze e ragazzi (misto)", + "ttFilterGroupM": "Solo ragazze", "adults": "Adulti (20+)", - "j19": "U19 (19 anni o meno)", + "j19": "J19", "j17": "U17 (17 anni o meno)", - "j15": "U15 (15 anni o meno)", - "j13": "U13 (13 anni o meno)", - "j11": "U11 (11 anni o meno)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "Reimposta filtri", "imageInternet": "Immagine (web?)", "testMember": "Prova", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 1bf12633..d755aea3 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -434,12 +434,26 @@ "clearFields": "入力欄をクリア", "showInactiveMembers": "非アクティブメンバーを表示", "ageGroup": "年齢区分", + "ttSeasonFilter": "シーズン(基準日)", + "ttSeasonCurrentTag": "今季", + "ttSeasonNextTag": "来季", + "ttStichtagHint": "基準日1月1日(DTTB)。男子:Jのみ。女子:JとM。", + "ttAgeClassCol": "年齢(TT)", + "ttAdult": "一般(基準日でジュニアではない)", + "ttFilterGroupJ": "男女混合", + "ttFilterGroupM": "女子のみ", "adults": "一般(20歳以上)", - "j19": "J19(19歳以下)", + "j19": "J19", "j17": "J17(17歳以下)", - "j15": "J15(15歳以下)", - "j13": "J13(13歳以下)", - "j11": "J11(11歳以下)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "フィルターをリセット", "imageInternet": "画像(公開)", "testMember": "体験", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 3dde26e5..f0676ad3 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -434,12 +434,26 @@ "clearFields": "Wyczyść pola", "showInactiveMembers": "Pokaż nieaktywnych członków", "ageGroup": "Kategoria wiekowa", + "ttSeasonFilter": "Sezon (termin)", + "ttSeasonCurrentTag": "bieżący", + "ttSeasonNextTag": "następny", + "ttStichtagHint": "Termin 1.01 (DTTB). Chłopcy: tylko klasy J. Dziewczęta: J i M.", + "ttAgeClassCol": "Kategoria (TT)", + "ttAdult": "Dorośli (brak juniora wg terminu)", + "ttFilterGroupJ": "Dziewczęta i chłopcy (mieszane)", + "ttFilterGroupM": "Tylko dziewczęta", "adults": "Dorośli (20+)", - "j19": "U19 (19 lat i mniej)", + "j19": "J19", "j17": "U17 (17 lat i mniej)", - "j15": "U15 (15 lat i mniej)", - "j13": "U13 (13 lat i mniej)", - "j11": "U11 (11 lat i mniej)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "Resetuj filtry", "imageInternet": "Zdjęcie (www?)", "testMember": "Próba", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 3edcb513..b9e1936c 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -434,12 +434,26 @@ "clearFields": "ล้างช่องข้อมูล", "showInactiveMembers": "แสดงสมาชิกที่ไม่ใช้งาน", "ageGroup": "กลุ่มอายุ", + "ttSeasonFilter": "ฤดูกาล (วันตัดสิทธิ์)", + "ttSeasonCurrentTag": "ปัจจุบัน", + "ttSeasonNextTag": "ถัดไป", + "ttStichtagHint": "วันตัดสิทธิ์ 1 ม.ค. (DTTB) ชาย: เฉพาะ J หญิง: J และ M", + "ttAgeClassCol": "รุ่นอายุ (TT)", + "ttAdult": "ผู้ใหญ่ (ไม่ใช่เยาวชนตามวันตัด)", + "ttFilterGroupJ": "ชายและหญิง (รวม)", + "ttFilterGroupM": "เฉพาะหญิง", "adults": "ผู้ใหญ่ (20+)", - "j19": "J19 (19 ปีหรือน้อยกว่า)", + "j19": "J19", "j17": "J17 (17 ปีหรือน้อยกว่า)", - "j15": "J15 (15 ปีหรือน้อยกว่า)", - "j13": "J13 (13 ปีหรือน้อยกว่า)", - "j11": "J11 (11 ปีหรือน้อยกว่า)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "รีเซ็ตตัวกรอง", "imageInternet": "รูปภาพ (เน็ต)", "testMember": "ทดลอง", diff --git a/frontend/src/i18n/locales/tl.json b/frontend/src/i18n/locales/tl.json index d49ce1e5..46e18de5 100644 --- a/frontend/src/i18n/locales/tl.json +++ b/frontend/src/i18n/locales/tl.json @@ -434,12 +434,26 @@ "clearFields": "Burahin ang mga field", "showInactiveMembers": "Ipakita ang mga hindi aktibong miyembro", "ageGroup": "Pangkat ng edad", + "ttSeasonFilter": "Season (cutoff)", + "ttSeasonCurrentTag": "kasalukuyan", + "ttSeasonNextTag": "susunod", + "ttStichtagHint": "Cutoff Enero 1 (DTTB). Lalaki: J lang. Babae: J at M.", + "ttAgeClassCol": "Edad (TT)", + "ttAdult": "Adulto (hindi youth sa cutoff)", + "ttFilterGroupJ": "Babae at lalaki (halo)", + "ttFilterGroupM": "Babae lamang", "adults": "Adulto (20+)", - "j19": "J19 (19 pababa)", + "j19": "J19", "j17": "J17 (17 pababa)", - "j15": "J15 (15 pababa)", - "j13": "J13 (13 pababa)", - "j11": "J11 (11 pababa)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "I-reset ang mga filter", "imageInternet": "Larawan (net?)", "testMember": "Trial", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 8df02eb1..8ade7071 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -434,12 +434,26 @@ "clearFields": "清空字段", "showInactiveMembers": "显示非活跃成员", "ageGroup": "年龄组", + "ttSeasonFilter": "赛季(截止日)", + "ttSeasonCurrentTag": "当前", + "ttSeasonNextTag": "下一", + "ttStichtagHint": "截止日1月1日(DTTB)。男孩:仅 J 组。女孩:J 与 M。", + "ttAgeClassCol": "年龄组(TT)", + "ttAdult": "成人(按截止日非青年)", + "ttFilterGroupJ": "男女孩(混合)", + "ttFilterGroupM": "仅女孩", "adults": "成人(20岁以上)", - "j19": "J19(19岁及以下)", + "j19": "J19", "j17": "J17(17岁及以下)", - "j15": "J15(15岁及以下)", - "j13": "J13(13岁及以下)", - "j11": "J11(11岁及以下)", + "j15": "J15", + "j13": "J13", + "j11": "J11", + "j9": "J9", + "m19": "M19", + "m15": "M15", + "m13": "M13", + "m11": "M11", + "m9": "M9", "clearFilters": "重置筛选", "imageInternet": "图片(网络)", "testMember": "试用", diff --git a/frontend/src/utils/ttAgeClass.js b/frontend/src/utils/ttAgeClass.js new file mode 100644 index 00000000..018a6c7c --- /dev/null +++ b/frontend/src/utils/ttAgeClass.js @@ -0,0 +1,128 @@ +/** + * Tischtennis-Altersklassen nach Stichtag 01.01. (z. B. DTTB / Mannschafts- und Einzelspielbetrieb). + * Pro Saison verschieben sich die Jahrgänge um ein Jahr; die Logik nutzt das erste Saisonjahr (z. B. 2025 für 2025/26). + */ + +const YOUTH_BANDS = [9, 11, 13, 15, 19]; + +/** + * @param {Date} [d] + * @returns {number} Erstes Kalenderjahr der laufenden Spielzeit (01.08.–31.07. üblich: Aug–Dez = Jahr, Jan–Jul = Vorjahr). + */ +export function getSeasonStartYearFromDate(d = new Date()) { + const month = d.getMonth() + 1; + const year = d.getFullYear(); + return month >= 8 ? year : year - 1; +} + +/** + * Stichtag 01.01. für „Mädchen & Jungen n“: Geburtsdatum >= 01.01.(Saisonbeginn − (n − 1)) + * Beispiel Saison 2025/26: J19 → 01.01.2007, J15 → 01.01.2011, …, J9 → 01.01.2017 + * @param {number} seasonStartYear z. B. 2025 + * @param {number} classNum 9 | 11 | 13 | 15 | 19 + */ +export function getStichtagDate(seasonStartYear, classNum) { + const y = seasonStartYear - (classNum - 1); + return new Date(y, 0, 1); +} + +function birthDateToTime(birthDate) { + if (!birthDate) return null; + let d; + if (birthDate instanceof Date && !Number.isNaN(birthDate.getTime())) { + d = birthDate; + } else if (typeof birthDate === 'string') { + const t = birthDate.trim(); + if (t.includes('-')) { + const [a, b, c] = t.split(/[T\s]/)[0].split('-').map(Number); + if (Number.isFinite(a) && Number.isFinite(b) && Number.isFinite(c)) { + d = new Date(a, b - 1, c); + } + } else if (t.includes('.')) { + const parts = t.split('.'); + if (parts.length >= 3) { + const day = parseInt(parts[0], 10); + const month = parseInt(parts[1], 10); + const year = parseInt(parts[2], 10); + if (Number.isFinite(day) && Number.isFinite(month) && Number.isFinite(year)) { + d = new Date(year, month - 1, day); + } + } + } + } + if (!d || Number.isNaN(d.getTime())) return null; + return d.getTime(); +} + +/** + * Engste Jugend-Altersklasse (exklusive Bänder zwischen den Stichtagen). + * @returns {'J9'|'J11'|'J13'|'J15'|'J19'|'adult'|null} null = kein gültiges Geburtsdatum + */ +export function getExclusiveJugendClass(birthDate, seasonStartYear) { + const t = birthDateToTime(birthDate); + if (t === null) return null; + + const c = (k) => getStichtagDate(seasonStartYear, k).getTime(); + const c9 = c(9); + const c11 = c(11); + const c13 = c(13); + const c15 = c(15); + const c19 = c(19); + + if (t < c19) return 'adult'; + if (t >= c9) return 'J9'; + if (t >= c11 && t < c9) return 'J11'; + if (t >= c13 && t < c11) return 'J13'; + if (t >= c15 && t < c13) return 'J15'; + if (t >= c19 && t < c15) return 'J19'; + return 'adult'; +} + +/** + * @param {object} member + * @param {string} filterKey '', 'adult', 'range', 'J9'…'J19', 'M9'…'M19' + * @param {number} seasonStartYear + * @returns {boolean} + */ +export function memberMatchesTtAgeClass(member, filterKey, seasonStartYear) { + if (!filterKey || filterKey === 'range') return true; + + const jClass = getExclusiveJugendClass(member.birthDate, seasonStartYear); + + if (filterKey === 'adult') { + return jClass === 'adult'; + } + + if (filterKey.startsWith('J')) { + return jClass === filterKey; + } + + if (filterKey.startsWith('M')) { + if (member.gender !== 'female') return false; + const want = `J${filterKey.slice(1)}`; + return jClass === want; + } + + return true; +} + +/** + * Anzeige in der Liste: Jungen nur J…; Mädchen J… und M… + */ +export function formatMemberTtAgeClassLabels(member, seasonStartYear) { + const j = getExclusiveJugendClass(member.birthDate, seasonStartYear); + if (j === null) return { primary: null, secondary: null }; + if (j === 'adult') return { primary: 'adult', secondary: null }; + if (member.gender === 'female') { + const m = `M${j.slice(1)}`; + return { primary: j, secondary: m }; + } + return { primary: j, secondary: null }; +} + +export function formatSeasonSlash(seasonStartYear) { + const y = seasonStartYear; + return `${y}/${String(y + 1).slice(-2)}`; +} + +export { YOUTH_BANDS }; diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 83b5228d..5948b8c8 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -12,6 +12,8 @@ :selected-member-scope="selectedMemberScope" :show-inactive-members="showInactiveMembers" :selected-age-group="selectedAgeGroup" + :selected-season-start-year="selectedSeasonStartYear" + :season-filter-options="ttSeasonFilterOptions" :selected-age-from="selectedAgeFrom" :selected-age-to="selectedAgeTo" :selected-gender="selectedGender" @@ -32,6 +34,7 @@ @update:selected-member-scope="selectedMemberScope = $event" @update:show-inactive-members="showInactiveMembers = $event" @update:selected-age-group="selectedAgeGroup = $event" + @update:selected-season-start-year="selectedSeasonStartYear = $event" @update:selected-age-from="selectedAgeFrom = $event" @update:selected-age-to="selectedAgeTo = $event" @update:selected-gender="selectedGender = $event" @@ -332,6 +335,7 @@ {{ $t('members.contact') }} {{ $t('members.birthdate') }} {{ $t('members.age') }} + {{ $t('members.ttAgeClassCol') }} {{ $t('members.lastTraining') }} {{ $t('members.trainingParticipations') }} {{ $t('members.actions') }} @@ -417,6 +421,7 @@ {{ getFormattedBirthdate(member.birthDate) }} {{ getAgeLabel(member.birthDate) }} + {{ formatMemberTtAgeClassCell(member) }} {{ getOptionalFormattedDate(member.lastTraining, 'members.previewNoLastTraining') }} {{ member.trainingParticipations || 0 }} @@ -567,6 +572,12 @@ import { mapGetters } from 'vuex'; import apiClient from '../apiClient.js'; import { getSafeErrorMessage, getSafeMessage } from '../utils/errorMessages.js'; +import { + getSeasonStartYearFromDate, + memberMatchesTtAgeClass, + formatMemberTtAgeClassLabels, + formatSeasonSlash +} from '../utils/ttAgeClass.js'; import PDFGenerator from '../components/PDFGenerator.js'; import InfoDialog from '../components/InfoDialog.vue'; @@ -625,6 +636,20 @@ export default { .map(([id, name]) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name, 'de-DE')); }, + + ttSeasonFilterOptions() { + const y0 = getSeasonStartYearFromDate(); + return [ + { + value: y0, + label: `${formatSeasonSlash(y0)} (${this.$t('members.ttSeasonCurrentTag')})` + }, + { + value: y0 + 1, + label: `${formatSeasonSlash(y0 + 1)} (${this.$t('members.ttSeasonNextTag')})` + } + ]; + }, filteredMembers() { return this.members.filter(member => { @@ -664,10 +689,9 @@ export default { return false; } - // Altersklasse Filter + // Altersklasse (TT, Stichtag 1.1.) if (this.selectedAgeGroup && this.selectedAgeGroup !== 'range') { - const age = this.getAge(member.birthDate); - if (!this.matchesAgeGroup(age, this.selectedAgeGroup)) { + if (!memberMatchesTtAgeClass(member, this.selectedAgeGroup, this.selectedSeasonStartYear)) { return false; } } @@ -940,6 +964,7 @@ export default { selectedGroupToAdd: '', showTransferDialog: false, selectedAgeGroup: '', + selectedSeasonStartYear: getSeasonStartYearFromDate(), selectedAgeFrom: '', selectedAgeTo: '', selectedGender: '', @@ -994,19 +1019,32 @@ export default { }, async init() { - try { - await this.loadMembers(); - } catch (error) { - console.error(this.$t('members.errorLoadingMembers'), error); - this.membersLoadError = getSafeErrorMessage(error, this.$t('members.errorLoadingMembers')); - } + await this.loadMembers(); }, async loadMembers() { this.isLoadingMembers = true; this.membersLoadError = ''; try { const response = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`); - this.members = response.data + const payload = response.data; + const ok = + response.status >= 200 && + response.status < 300 && + Array.isArray(payload); + if (!ok) { + const raw = + payload && + typeof payload === 'object' && + !Array.isArray(payload) && + (payload.error ?? payload.message); + this.membersLoadError = getSafeMessage( + typeof raw === 'string' ? raw : null, + this.$t('members.errorLoadingMembers') + ); + this.members = []; + return; + } + this.members = payload .map(member => this.applyBackendBaseUrlToMember(member)) .sort((a, b) => { const lastNameA = a.lastName ? a.lastName.toLowerCase() : ''; @@ -1031,6 +1069,10 @@ export default { this.selectedPreviewTrainingGroups = []; } } + } catch (error) { + console.error(this.$t('members.errorLoadingMembers'), error); + this.membersLoadError = getSafeErrorMessage(error, this.$t('members.errorLoadingMembers')); + this.members = []; } finally { this.isLoadingMembers = false; } @@ -1181,6 +1223,7 @@ export default { this.$t('members.status'), this.$t('members.birthdate'), this.$t('members.age'), + this.$t('members.ttAgeClassCol'), this.$t('members.contact'), this.$t('members.emailAddress') ]; @@ -1190,6 +1233,7 @@ export default { this.getMemberStatusBadges(member).map(badge => badge.label).join(' | '), this.getFormattedBirthdate(member.birthDate), this.getAgeLabel(member.birthDate), + this.formatMemberTtAgeClassCell(member), this.getFormattedPhoneNumbers(member), this.getFormattedEmails(member) ]); @@ -2362,40 +2406,23 @@ export default { : age; }, - matchesAgeGroup(age, ageGroup) { - if (age === null) return false; - - // Tischtennis-Logik: Altersklassen basieren auf Jahrgängen mit Stichtag 1. Januar - // J19 = 19 Jahre und jünger am 1. Januar des aktuellen Jahres - // J17 = 17 Jahre und jünger am 1. Januar des aktuellen Jahres - // etc. - - switch (ageGroup) { - case 'adult': - return age > 19; // Erwachsene = älter als 19 - - case 'J19': - return age <= 19; // J19 = 19 und jünger - - case 'J17': - return age <= 17; // J17 = 17 und jünger - - case 'J15': - return age <= 15; // J15 = 15 und jünger - - case 'J13': - return age <= 13; // J13 = 13 und jünger - - case 'J11': - return age <= 11; // J11 = 11 und jünger - - default: - return true; + formatMemberTtAgeClassCell(member) { + const { primary, secondary } = formatMemberTtAgeClassLabels(member, this.selectedSeasonStartYear); + if (primary === null) { + return '–'; } + if (primary === 'adult') { + return this.$t('members.ttAdult'); + } + if (secondary) { + return `${primary} · ${secondary}`; + } + return primary; }, clearFilters() { this.selectedAgeGroup = ''; + this.selectedSeasonStartYear = getSeasonStartYearFromDate(); this.selectedAgeFrom = ''; this.selectedAgeTo = ''; this.selectedGender = ''; @@ -3319,6 +3346,11 @@ table td { padding: 0.5rem 1rem 1rem; } +.tt-age-class-cell { + font-size: 0.88rem; + white-space: nowrap; +} + .action-icons-row { display: flex; gap: 0.75rem;