Fügt Unterstützung für offizielle Turniere und Wettbewerbe hinzu. Aktualisiert die Datenbankmodelle, um Geschlecht für Mitglieder zu erfassen, und implementiert neue Routen sowie Frontend-Komponenten zur Anzeige und Verwaltung dieser Daten. Verbessert die Benutzeroberfläche zur Eingabe von Mitgliederdaten und aktualisiert die Abhängigkeiten im Projekt.

This commit is contained in:
Torsten Schulz (local)
2025-08-30 23:16:39 +02:00
parent b82a80a11d
commit 975800c1ab
25 changed files with 1450 additions and 259 deletions

View File

@@ -52,6 +52,10 @@
<span class="nav-icon">🏆</span>
Turniere
</a>
<a href="/official-tournaments" class="nav-link">
<span class="nav-icon">📄</span>
Offizielle Turniere
</a>
<a href="/predefined-activities" class="nav-link">
<span class="nav-icon"></span>
Vordefinierte Aktivitäten

View File

@@ -12,6 +12,7 @@ import ScheduleView from './views/ScheduleView.vue';
import TournamentsView from './views/TournamentsView.vue';
import TrainingStatsView from './views/TrainingStatsView.vue';
import PredefinedActivities from './views/PredefinedActivities.vue';
import OfficialTournaments from './views/OfficialTournaments.vue';
const routes = [
{ path: '/register', component: Register },
@@ -27,6 +28,7 @@ const routes = [
{ path: '/tournaments', component: TournamentsView },
{ path: '/training-stats', component: TrainingStatsView },
{ path: '/predefined-activities', component: PredefinedActivities },
{ path: '/official-tournaments', component: OfficialTournaments },
];
const router = createRouter({

View File

@@ -20,6 +20,14 @@
<label><span>Geburtsdatum:</span> <input type="date" v-model="newBirthdate"></label>
<label><span>Telefon-Nr.:</span> <input type="text" v-model="newPhone"></label>
<label><span>Email-Adresse:</span> <input type="email" v-model="newEmail"></label>
<label><span>Geschlecht:</span>
<select v-model="newGender">
<option value="unknown">Unbekannt</option>
<option value="male">Männlich</option>
<option value="female">Weiblich</option>
<option value="diverse">Divers</option>
</select>
</label>
<label class="checkbox-item"><span>Aktiv:</span> <input type="checkbox" v-model="newActive"></label>
<label class="checkbox-item"><span>Pics in Internet erlaubt:</span> <input type="checkbox" v-model="newPicsInInternetAllowed"></label>
<label class="checkbox-item"><span>Testmitgliedschaft:</span> <input type="checkbox" v-model="testMembership"></label>
@@ -52,6 +60,7 @@
<th>Geburtsdatum</th>
<th>Telefon-Nr.</th>
<th>Email-Adresse</th>
<th>Geschlecht</th>
<th></th>
</tr>
</thead>
@@ -70,6 +79,7 @@
<td>{{ getFormattedBirthdate(member.birthDate) }}</td>
<td>{{ member.phone }}</td>
<td>{{ member.email }}</td>
<td>{{ member.gender || 'unknown' }}</td>
<td>
<button @click.stop="openNotesModal(member)">Notizen</button>
</td>
@@ -123,9 +133,10 @@ export default {
newLastname: '',
newStreet: '',
newCity: '',
newBirthdate: '01.01.2010',
newBirthdate: '',
newPhone: '',
newEmail: '',
newGender: 'unknown',
newActive: true,
memberToEdit: null,
memberImage: null,
@@ -174,12 +185,13 @@ export default {
this.newLastname = '';
this.newStreet = '';
this.newCity = '';
this.newBirthdate = '01.01.2010';
this.newBirthdate = '';
this.newPhone = '';
this.newEmail = '';
this.newActive = true;
this.newPicsInInternetAllowed = false;
this.testMembership = true;
this.newGender = 'unknown';
this.memberImage = null;
this.memberImagePreview = null;
},
@@ -203,6 +215,7 @@ export default {
birthdate: this.newBirthdate,
phone: this.newPhone,
email: this.newEmail,
gender: this.newGender,
active: this.newActive,
id: this.memberToEdit ? this.memberToEdit.id : null,
testMembership: this.testMembership,
@@ -246,8 +259,9 @@ export default {
this.newCity = member.city;
this.newPhone = member.phone;
this.newEmail = member.email;
this.newGender = member.gender || 'unknown';
this.newActive = member.active;
this.newBirthdate = date.toISOString().split('T')[0];
this.newBirthdate = this.formatDateForInput(birthDate);
this.testMembership = member.testMembership;
this.newPicsInInternetAllowed = member.picsInInternetAllowed;
try {
@@ -260,6 +274,22 @@ export default {
this.memberImagePreview = null;
}
},
formatDateForInput(value) {
if (!value || typeof value !== 'string') return '';
const v = value.trim();
// dd.mm.yyyy
const m1 = v.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m1) {
const yyyy = m1[3];
const mm = String(Number(m1[2])).padStart(2, '0');
const dd = String(Number(m1[1])).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
// ISO/yyy-mm-dd
const d = new Date(v);
if (!isNaN(d.getTime())) return d.toISOString().split('T')[0];
return '';
},
resetToNewMember() {
this.memberToEdit = null;
this.resetNewMember();

View File

@@ -0,0 +1,344 @@
<template>
<div class="official-tournaments">
<h2>Offizielle Turniere</h2>
<div v-if="list && list.length" class="list">
<h3>Gespeicherte Veranstaltungen</h3>
<ul>
<li v-for="t in list" :key="t.id" style="display:flex; align-items:center; gap:.5rem;">
<a href="#" @click.prevent="uploadedId = String(t.id); reload();" style="flex:1;">
{{ t.title || ('Turnier #' + t.id) }}
</a>
<span v-if="t.termin || t.eventDate"> {{ t.termin || t.eventDate }}</span>
<button class="btn-secondary" @click.prevent="removeTournament(t)" title="Löschen">🗑</button>
</li>
</ul>
</div>
<div class="uploader">
<input type="file" accept="application/pdf" @change="onFile" />
<button class="btn-primary" :disabled="!selectedFile" @click="upload">PDF hochladen</button>
</div>
<div v-if="parsed">
<div class="meta">
<div><strong>Titel:</strong> {{ parsed.parsedData.title || '' }}</div>
<div><strong>Termin:</strong> {{ parsed.parsedData.termin || '' }}</div>
<div><strong>Austragungsorte:</strong>
<ul>
<li v-for="(o,i) in parsed.parsedData.austragungsorte" :key="i">{{ o }}</li>
</ul>
</div>
<div><strong>Konkurrenztypen:</strong> {{ (parsed.parsedData.konkurrenztypen||[]).join(', ') || '' }}</div>
<div><strong>Meldeschlüsse:</strong> {{ (parsed.parsedData.meldeschluesse||[]).join(' | ') || '' }}</div>
<div><strong>Altersklassen:</strong> {{ (parsed.parsedData.altersklassen||[]).join(', ') || '' }}</div>
<div><strong>Startzeiten:</strong>
<span v-for="(t,ak) in parsed.parsedData.startzeiten" :key="ak" style="margin-right:.5rem;">{{ ak }}: {{ t }}</span>
</div>
<div v-if="parsed.parsedData.meldeschluesseByAk && Object.keys(parsed.parsedData.meldeschluesseByAk).length">
<strong>Meldeschlüsse je AK:</strong>
<span v-for="(arr,ak) in parsed.parsedData.meldeschluesseByAk" :key="ak" style="margin-right:.5rem;">
{{ ak }}: {{ arr.join(', ') }}
</span>
</div>
</div>
<!-- ehemals 'Erkannte Einträge' entfernt -->
</div>
<div v-if="parsed && parsed.parsedData.competitions && parsed.parsedData.competitions.length">
<h3>Konkurrenzen</h3>
<table>
<thead>
<tr>
<th></th>
<th>Altersklasse/Wettbewerb</th>
<th>Startzeit</th>
<th>Startgeld</th>
</tr>
</thead>
<tbody>
<template v-for="(c,idx) in parsed.parsedData.competitions" :key="idx">
<tr>
<td style="width:2.5rem;">
<button class="btn-secondary" @click.prevent="toggleRow(c, idx)" :aria-expanded="isExpanded(c, idx)">
{{ isExpanded(c, idx) ? '▾' : '▸' }}
</button>
</td>
<td>{{ c.ageClassCompetition || c.altersklasseWettbewerb }}</td>
<td>{{ c.startTime || c.startzeit || '' }}</td>
<td>{{ c.entryFee || c.startgeld || '' }}</td>
</tr>
<tr v-if="isExpanded(c, idx)" class="comp-details">
<td :colspan="4">
<div class="details">
<div class="detail-item"><strong>Meldeschluss (Datum):</strong> {{ c.registrationDeadlineDate || c.meldeschlussDatum || '' }}</div>
<div class="detail-item"><strong>Meldeschluss (Online):</strong> {{ c.registrationDeadlineOnline || c.meldeschlussOnline || '' }}</div>
<div class="detail-item"><strong>Stichtag:</strong> {{ c.cutoffDate || c.stichtag || '' }}</div>
<div class="detail-item"><strong>Offen für:</strong> {{ c.openTo || c.offenFuer || '' }}</div>
<div class="detail-item"><strong>Vorrunde:</strong> {{ c.preliminaryRound || c.vorrunde || '' }}</div>
<div class="detail-item"><strong>Endrunde:</strong> {{ c.finalRound || c.endrunde || '' }}</div>
<div class="detail-item">
<strong>Teilnahmeberechtigt ({{ eligibleMembers(c).length }}):</strong>
<table class="eligible-table" v-if="eligibleMembers(c).length">
<thead>
<tr>
<th>Name</th>
<th>Geburtsdatum</th>
<th>Alter</th>
</tr>
</thead>
<tbody>
<tr v-for="m in eligibleMembers(c)" :key="m.id">
<td>{{ m.firstName }} {{ m.lastName }}</td>
<td>{{ formatDateStr(m.birthDate) }}</td>
<td>{{ ageOnRef(m, c) ?? '' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<script>
import apiClient from '../apiClient.js';
import { mapGetters } from 'vuex';
export default {
name: 'OfficialTournaments',
data() {
return {
selectedFile: null,
uploadedId: null,
parsed: null,
members: [],
matches: {},
list: [],
expanded: {},
};
},
computed: {
...mapGetters(['currentClub'])
},
methods: {
onFile(e) {
this.selectedFile = e.target.files && e.target.files[0] ? e.target.files[0] : null;
},
isExpanded(c, idx) {
return !!this.expanded[String(idx)];
},
toggleRow(c, idx) {
const k = String(idx);
this.$set ? this.$set(this.expanded, k, !this.expanded[k]) : (this.expanded[k] = !this.expanded[k]);
},
async upload() {
if (!this.selectedFile) return;
const fd = new FormData();
fd.append('pdf', this.selectedFile);
const r = await apiClient.post(`/official-tournaments/${this.currentClub}/upload`, fd, {
headers: { 'Content-Type': 'multipart/form-data' }
});
this.uploadedId = r.data.id;
await this.reload();
await this.loadList();
},
async reload() {
if (!this.uploadedId) return;
const t = await apiClient.get(`/official-tournaments/${this.currentClub}/${this.uploadedId}`);
this.parsed = t.data;
// Mitglieder laden (alle aktiv)
const m = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
this.members = m.data;
},
async loadList() {
const r = await apiClient.get(`/official-tournaments/${this.currentClub}`);
this.list = r.data;
},
// Eligibility: Datum parsen (dd.mm.yyyy | yyyy-mm-dd | ISO)
parseDateFlexible(s) {
if (!s || typeof s !== 'string') return null;
const t = s.trim();
let m = t.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m) {
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
return isNaN(d.getTime()) ? null : d;
}
m = t.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
return isNaN(d.getTime()) ? null : d;
}
const d = new Date(t);
return isNaN(d.getTime()) ? null : d;
},
getCutoffDate(c) {
return this.parseDateFlexible(c.cutoffDate || c.stichtag || '');
},
getMemberBirthDate(member) {
return this.parseDateFlexible(member.birthDate || '');
},
// Referenzdatum für Altersberechnung: Startzeit-Datum > Turnier-Termin
getReferenceDate(c) {
const fromStart = (c.startTime || c.startzeit || '').match(/(\d{1,2}\.\d{1,2}\.\d{4})/);
if (fromStart) return this.parseDateFlexible(fromStart[1]);
return this.parseDateFlexible(this.parsed?.parsedData?.termin || '');
},
// Uxx oder AK xx erkennen -> Maximalalter in Jahren
getAgeLimitFromText(text) {
if (!text) return null;
const t = String(text).toUpperCase();
let m = t.match(/\bU\s*(\d{1,2})\b/);
if (m) return Number(m[1]);
m = t.match(/\bAK\s*(\d{1,2})\b/);
if (m) return Number(m[1]);
return null;
},
calculateAgeOnDate(birthDate, refDate) {
if (!birthDate || !refDate) return null;
let age = refDate.getFullYear() - birthDate.getFullYear();
const m = refDate.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && refDate.getDate() < birthDate.getDate())) age--;
return age;
},
getGenderRule(c) {
const txt = `${c.ageClassCompetition || c.altersklasseWettbewerb || ''} ${c.openTo || c.offenFuer || ''}`.toLowerCase();
if (/(mädchen|weiblich|\bw\b)/.test(txt)) return 'female';
if (/(jungen|männlich|\bm\b)/.test(txt)) return 'male';
if (/jugend/.test(txt)) return 'both';
return 'both';
},
isEligibleByAge(member, c) {
const cutoff = this.getCutoffDate(c);
const bd = this.getMemberBirthDate(member);
if (cutoff) {
if (!bd || !(bd.getTime() > cutoff.getTime())) return false; // jünger als Stichtag
}
const limit = this.getAgeLimitFromText((c.ageClassCompetition || c.altersklasseWettbewerb || ''));
if (limit != null) {
const ref = this.getReferenceDate(c);
const age = this.calculateAgeOnDate(bd, ref);
if (age == null || !(age < limit)) return false; // Uxx => Alter < xx
}
return true;
},
isEligibleForCompetition(member, c) {
// Geschlecht
const rule = this.getGenderRule(c);
const g = member.gender || 'unknown';
if (rule === 'female' && g !== 'female') return false;
if (rule === 'male' && g !== 'male') return false;
// Alter (Stichtag UND/ODER U/AK-Regel; beide greifen, wenn vorhanden)
if (!this.isEligibleByAge(member, c)) return false;
return true;
},
eligibleMembers(c) {
return (this.members || []).filter(m => this.isEligibleForCompetition(m, c));
},
ageOnRef(member, c) {
const bd = this.getMemberBirthDate(member);
const ref = this.getReferenceDate(c);
return this.calculateAgeOnDate(bd, ref);
},
formatDateStr(s) {
const d = this.parseDateFlexible(s);
if (!d) return '';
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yyyy = d.getFullYear();
return `${dd}.${mm}.${yyyy}`;
},
// Eligibility helpers
parseDateFlexible(s) {
if (!s || typeof s !== 'string') return null;
const t = s.trim();
let m = t.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m) {
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
return isNaN(d.getTime()) ? null : d;
}
m = t.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
return isNaN(d.getTime()) ? null : d;
}
const d = new Date(t);
return isNaN(d.getTime()) ? null : d;
},
getCutoffDate(c) {
return this.parseDateFlexible(c.cutoffDate || c.stichtag || '');
},
getMemberBirthDate(member) {
return this.parseDateFlexible(member.birthDate || '');
},
getGenderRule(c) {
const txt = `${c.ageClassCompetition || c.altersklasseWettbewerb || ''} ${c.openTo || c.offenFuer || ''}`.toLowerCase();
if (/(mädchen|weiblich|\bw\b)/.test(txt)) return 'female';
if (/(jungen|männlich|\bm\b)/.test(txt)) return 'male';
if (/jugend/.test(txt)) return 'both';
return 'both';
},
isEligibleForCompetition(member, c) {
const rule = this.getGenderRule(c);
const g = member.gender || 'unknown';
if (rule === 'female' && g !== 'female') return false;
if (rule === 'male' && g !== 'male') return false;
const cutoff = this.getCutoffDate(c);
if (cutoff) {
const bd = this.getMemberBirthDate(member);
if (!bd) return false;
if (!(bd.getTime() > cutoff.getTime())) return false; // jünger als Stichtag => geboren nach Stichtag
}
return true;
},
eligibleMembers(c) {
return (this.members || []).filter(m => this.isEligibleForCompetition(m, c));
},
async removeTournament(t) {
if (!confirm(`Turnier wirklich löschen?\n${t.title || 'Ohne Titel'} (ID ${t.id})`)) return;
await apiClient.delete(`/official-tournaments/${this.currentClub}/${t.id}`);
if (String(this.uploadedId) === String(t.id)) {
this.parsed = null;
this.uploadedId = null;
}
await this.loadList();
},
suggestMembers(entry) {
const name = (entry.name || '').toLowerCase();
const parts = name.split(/\s+/).filter(Boolean);
return this.members.filter(m => {
const fn = (m.firstName || '').toLowerCase();
const ln = (m.lastName || '').toLowerCase();
const match = parts.some(p => fn.includes(p) || ln.includes(p));
if (!match) return false;
if (!entry.genderHint) return true;
if (entry.genderHint === 'm') return (m.gender || 'unknown') === 'male';
if (entry.genderHint === 'w') return (m.gender || 'unknown') === 'female';
return true;
}).slice(0, 10);
},
},
async mounted() {
await this.loadList();
}
};
</script>
<style scoped>
.official-tournaments { display: flex; flex-direction: column; gap: 0.75rem; }
.uploader { display: flex; gap: 0.5rem; align-items: center; }
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid var(--border-color); padding: 0.5rem; text-align: left; }
.comp-details td { background: var(--background-light, #fafafa); }
.details { display: grid; grid-template-columns: 1fr; gap: .4rem 0; padding: .5rem 0; }
.detail-item { font-size: .95rem; }
.eligible-list { margin-top: .25rem; display: flex; flex-wrap: wrap; gap: .25rem .5rem; }
.eligible-name { background: var(--background, #f1f1f1); border: 1px solid var(--border-color, #ddd); border-radius: 4px; padding: 2px 6px; }
.eligible-table { width: 100%; border-collapse: collapse; margin-top: .25rem; }
.eligible-table th, .eligible-table td { border-bottom: 1px solid var(--border-color); padding: .25rem .4rem; text-align: left; }
</style>