Fügt die Methode listClubParticipations im OfficialTournamentController hinzu, um die Teilnahme von Mitgliedern an offiziellen Turnieren zu listen. Aktualisiert die Routen, um diese neue Funktionalität zu integrieren. Verbessert die Benutzeroberfläche in OfficialTournaments.vue mit Tabs zur Anzeige von Veranstaltungen und Turnierbeteiligungen sowie einer Filteroption für den Zeitraum der Beteiligungen.
This commit is contained in:
@@ -2,17 +2,22 @@
|
||||
<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="tabs">
|
||||
<button :class="['tab', topActiveTab==='events' ? 'active' : '']" @click="switchTopTab('events')" title="Gespeicherte Veranstaltungen anzeigen">Veranstaltungen</button>
|
||||
<button :class="['tab', topActiveTab==='participations' ? 'active' : '']" @click="switchTopTab('participations')" title="Turnierbeteiligungen anzeigen">Turnierbeteiligungen</button>
|
||||
</div>
|
||||
<div v-if="topActiveTab==='events'">
|
||||
<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 class="uploader">
|
||||
<input type="file" accept="application/pdf" @change="onFile" />
|
||||
<button class="btn-primary" :disabled="!selectedFile" @click="upload">PDF hochladen</button>
|
||||
@@ -48,6 +53,11 @@
|
||||
<button class="btn-secondary" @click="openMemberDialog" :disabled="!parsed || !activeMembers.length">Mitglieder auswählen</button>
|
||||
<button class="btn-primary" :disabled="!selectedMemberIds.length" @click="generateMembersPdf">PDF für markierte Mitglieder</button>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button :class="['tab', activeTab==='competitions' ? 'active' : '']" @click="activeTab='competitions'" title="Konkurrenzen anzeigen">Konkurrenzen</button>
|
||||
<button :class="['tab', activeTab==='results' ? 'active' : '']" @click="activeTab='results'" title="Ergebnisse anzeigen">Ergebnisse</button>
|
||||
</div>
|
||||
<div v-if="activeTab==='competitions'">
|
||||
<h3>Konkurrenzen</h3>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -62,7 +72,7 @@
|
||||
<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)">
|
||||
<button class="btn-secondary" @click.prevent="toggleRow(c, idx)" :aria-expanded="isExpanded(c, idx)" title="Details ein-/ausklappen">
|
||||
{{ isExpanded(c, idx) ? '▾' : '▸' }}
|
||||
</button>
|
||||
</td>
|
||||
@@ -133,6 +143,66 @@
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h3>Ergebnisse</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitglied</th>
|
||||
<th>Konkurrenz</th>
|
||||
<th>Startzeit</th>
|
||||
<th>Angemeldet</th>
|
||||
<th>Teilgenommen</th>
|
||||
<th>Platzierung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in resultsRows" :key="row.key">
|
||||
<td>{{ row.memberName }}</td>
|
||||
<td>{{ row.competitionName }}</td>
|
||||
<td>{{ row.start }}</td>
|
||||
<td>{{ row.registered ? 'Ja' : 'Nein' }}</td>
|
||||
<td>{{ row.participated ? 'Ja' : 'Nein' }}</td>
|
||||
<td>{{ row.placement || '–' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="topActiveTab==='participations'">
|
||||
<h3>Turnierbeteiligungen</h3>
|
||||
<div class="filters">
|
||||
<label for="participationRange">Zeitraum:</label>
|
||||
<select id="participationRange" v-model="participationRange" @change="onParticipationRangeChange">
|
||||
<option value="3m">Letzte 3 Monate</option>
|
||||
<option value="6m">Letzte 6 Monate</option>
|
||||
<option value="12m">Letzte 12 Monate</option>
|
||||
<option value="2y">Letzte 2 Jahre</option>
|
||||
<option value="prev">Vorherige Saison</option>
|
||||
<option value="all">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitglied</th>
|
||||
<th>Konkurrenz</th>
|
||||
<th>Datum</th>
|
||||
<th>Platzierung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in clubParticipationRows()" :key="row.key">
|
||||
<td>{{ row.memberName }}</td>
|
||||
<td>{{ row.competitionName }}</td>
|
||||
<td>{{ row.date }}</td>
|
||||
<td>{{ row.placement || '–' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMemberDialog" class="modal-overlay" @click.self="closeMemberDialog">
|
||||
@@ -197,12 +267,21 @@ export default {
|
||||
memberRecommendations: {},
|
||||
selectedMemberIdForDialog: null,
|
||||
participationMap: {}, // key: `${competitionId}-${memberId}` => { wants, registered, participated, placement }
|
||||
collator: new Intl.Collator('de', { sensitivity: 'base' }),
|
||||
activeTab: 'competitions',
|
||||
topActiveTab: 'events',
|
||||
loadingClubParticipations: false,
|
||||
clubParticipationRowsData: [],
|
||||
participationRange: 'all',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['currentClub']),
|
||||
activeMembers() {
|
||||
return (this.members || []).filter(m => m.active === true);
|
||||
return (this.members || [])
|
||||
.filter(m => m.active === true)
|
||||
.slice()
|
||||
.sort((a, b) => this.compareMembers(a, b));
|
||||
},
|
||||
selectedMemberInDialog() {
|
||||
const id = this.selectedMemberIdForDialog;
|
||||
@@ -213,9 +292,151 @@ export default {
|
||||
const m = this.selectedMemberInDialog;
|
||||
if (!m) return [];
|
||||
return this.competitionsForMember(m);
|
||||
},
|
||||
resultsRows() {
|
||||
const comps = (this.parsed?.parsedData?.competitions) || [];
|
||||
const compById = Object.fromEntries(comps.map(c => [String(c.id), c]));
|
||||
const rows = [];
|
||||
const entries = this.participationMap || {};
|
||||
for (const [key, p] of Object.entries(entries)) {
|
||||
if (!p || !p.participated) continue;
|
||||
const [competitionId, memberId] = key.split('-');
|
||||
const c = compById[String(competitionId)];
|
||||
if (!c) continue;
|
||||
const mname = this.memberNameById(memberId);
|
||||
const start = String(c.startTime || c.startzeit || '–');
|
||||
rows.push({
|
||||
key: `${competitionId}-${memberId}`,
|
||||
memberName: mname,
|
||||
competitionName: c.ageClassCompetition || c.altersklasseWettbewerb || '',
|
||||
start,
|
||||
registered: !!p.registered,
|
||||
participated: !!p.participated,
|
||||
placement: p.placement || null,
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => {
|
||||
const m = this.collator.compare(a.memberName, b.memberName);
|
||||
if (m !== 0) return m;
|
||||
return this.collator.compare(a.competitionName, b.competitionName);
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchTopTab(tab) { this.topActiveTab = tab; if (tab === 'participations') this.loadClubParticipations(); },
|
||||
compareMembers(a, b) {
|
||||
const fnA = String(a.firstName || '');
|
||||
const fnB = String(b.firstName || '');
|
||||
const lnA = String(a.lastName || '');
|
||||
const lnB = String(b.lastName || '');
|
||||
const byFirst = this.collator.compare(fnA, fnB);
|
||||
if (byFirst !== 0) return byFirst;
|
||||
return this.collator.compare(lnA, lnB);
|
||||
},
|
||||
clubParticipationRows() {
|
||||
if (this.clubParticipationRowsData && this.clubParticipationRowsData.length) {
|
||||
return this.clubParticipationRowsData;
|
||||
}
|
||||
// Fallback: nur aktuelles Turnier
|
||||
const rows = [];
|
||||
if (!this.parsed || !this.parsed.parsedData) return rows;
|
||||
const parts = (this.parsed.participation) || [];
|
||||
const comps = this.parsed.parsedData.competitions || [];
|
||||
const compById = Object.fromEntries(comps.map(c => [String(c.id), c]));
|
||||
for (const p of parts) {
|
||||
if (!p.participated) continue;
|
||||
const c = compById[String(p.competitionId)];
|
||||
if (!c) continue;
|
||||
const mname = this.memberNameById(p.memberId);
|
||||
const date = (String(c.startTime || c.startzeit || '')).match(/(\d{1,2}\.\d{1,2}\.\d{4})/);
|
||||
rows.push({
|
||||
key: `club-${p.competitionId}-${p.memberId}`,
|
||||
memberName: mname,
|
||||
competitionName: c.ageClassCompetition || c.altersklasseWettbewerb || '',
|
||||
date: date ? date[1] : '–',
|
||||
placement: p.placement || null,
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => {
|
||||
const byMember = this.collator.compare(a.memberName, b.memberName);
|
||||
if (byMember !== 0) return byMember;
|
||||
return this.collator.compare(a.competitionName, b.competitionName);
|
||||
});
|
||||
},
|
||||
async ensureMembersLoaded() {
|
||||
if (!this.members || !this.members.length) {
|
||||
const m = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
|
||||
this.members = m.data;
|
||||
}
|
||||
},
|
||||
computeRange() {
|
||||
const now = new Date();
|
||||
let from = null, to = null;
|
||||
switch (this.participationRange) {
|
||||
case '3m': from = new Date(now); from.setMonth(from.getMonth() - 3); break;
|
||||
case '6m': from = new Date(now); from.setMonth(from.getMonth() - 6); break;
|
||||
case '12m': from = new Date(now); from.setMonth(from.getMonth() - 12); break;
|
||||
case '2y': from = new Date(now); from.setFullYear(from.getFullYear() - 2); break;
|
||||
case 'prev': {
|
||||
const y = now.getMonth() + 1 >= 7 ? now.getFullYear() : now.getFullYear() - 1;
|
||||
from = new Date(y - 1, 6, 1); // 01.07.(y-1)
|
||||
to = new Date(y, 5, 30, 23, 59, 59, 999); // 30.06.y
|
||||
break;
|
||||
}
|
||||
case 'all':
|
||||
default:
|
||||
from = null; to = null; break;
|
||||
}
|
||||
return { from, to };
|
||||
},
|
||||
dateFromDmy(dmy) {
|
||||
if (!dmy) return null;
|
||||
const m = String(dmy).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
},
|
||||
onParticipationRangeChange() {
|
||||
// Neu laden, damit serverseitig bereits gefiltert werden könnte; ansonsten clientseitig filtern
|
||||
this.loadClubParticipations();
|
||||
},
|
||||
async loadClubParticipations() {
|
||||
if (this.loadingClubParticipations) return;
|
||||
this.loadingClubParticipations = true;
|
||||
try {
|
||||
await this.ensureMembersLoaded();
|
||||
// neuen kompakten EP nutzen
|
||||
const r = await apiClient.get(`/official-tournaments/${this.currentClub}/participations/summary`);
|
||||
const rows = [];
|
||||
const { from, to } = this.computeRange();
|
||||
for (const t of (r.data || [])) {
|
||||
for (const e of (t.entries || [])) {
|
||||
const d = this.dateFromDmy(e.date || t.startDate || null);
|
||||
if (from && d && d < from) continue;
|
||||
if (to && d && d > to) continue;
|
||||
rows.push({
|
||||
key: `club-${t.tournamentId}-${e.competitionId}-${e.memberId}`,
|
||||
memberName: e.memberName,
|
||||
competitionName: e.competitionName,
|
||||
date: e.date || t.startDate || '–',
|
||||
placement: e.placement || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
rows.sort((a, b) => {
|
||||
const byMember = this.collator.compare(a.memberName, b.memberName);
|
||||
if (byMember !== 0) return byMember;
|
||||
return this.collator.compare(a.competitionName, b.competitionName);
|
||||
});
|
||||
this.clubParticipationRowsData = rows;
|
||||
} finally {
|
||||
this.loadingClubParticipations = false;
|
||||
}
|
||||
},
|
||||
memberNameById(id) {
|
||||
const m = (this.members || []).find(x => String(x.id) === String(id));
|
||||
return m ? `${m.firstName} ${m.lastName}` : `#${id}`;
|
||||
},
|
||||
onFile(e) {
|
||||
this.selectedFile = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
||||
},
|
||||
@@ -444,8 +665,11 @@ export default {
|
||||
if (!this.isEligibleByAge(member, c)) return false;
|
||||
return true;
|
||||
},
|
||||
eligibleMembers(c) {
|
||||
return (this.members || []).filter(m => this.isEligibleForCompetition(m, c));
|
||||
eligibleMembers(c) {
|
||||
return (this.members || [])
|
||||
.filter(m => this.isEligibleForCompetition(m, c))
|
||||
.slice()
|
||||
.sort((a, b) => this.compareMembers(a, b));
|
||||
},
|
||||
ageOnRef(member, c) {
|
||||
const bd = this.getMemberBirthDate(member);
|
||||
@@ -503,9 +727,7 @@ export default {
|
||||
}
|
||||
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}`);
|
||||
@@ -539,11 +761,20 @@ export default {
|
||||
<style scoped>
|
||||
.official-tournaments { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.top-actions { display: flex; gap: .5rem; margin-bottom: .5rem; }
|
||||
.tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--border-color); margin: .25rem 0 .5rem; }
|
||||
.tab { background: #f8f9fb; color: var(--text-color, #222); border: none; padding: .4rem .6rem; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
.tab:hover { background: #eef1f5; }
|
||||
.tab.active { border-bottom-color: var(--primary, #2b7cff); font-weight: bold; color: var(--primary, #2b7cff); background: #e9f1ff; }
|
||||
.filters { display: flex; gap: .5rem; align-items: center; margin: .5rem 0; }
|
||||
.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; }
|
||||
.official-tournaments .btn-primary { color: #fff; }
|
||||
.official-tournaments .btn-secondary { color: #222; }
|
||||
.official-tournaments .btn-primary:disabled,
|
||||
.official-tournaments .btn-secondary:disabled { opacity: .6; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user