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;
|