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:
Torsten Schulz (local)
2025-09-11 14:11:19 +02:00
parent df02e48cfd
commit 48cd0921df
3 changed files with 357 additions and 19 deletions

View File

@@ -5,6 +5,8 @@ import { checkAccess } from '../utils/userUtils.js';
import OfficialTournament from '../models/OfficialTournament.js';
import OfficialCompetition from '../models/OfficialCompetition.js';
import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js';
import Member from '../models/Member.js';
import { Op } from 'sequelize';
// In-Memory Store (einfacher Start); später DB-Modell
const parsedTournaments = new Map(); // key: id, value: { id, clubId, rawText, parsedData }
@@ -170,6 +172,110 @@ export const listOfficialTournaments = async (req, res) => {
}
};
export const listClubParticipations = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
await checkAccess(userToken, clubId);
const tournaments = await OfficialTournament.findAll({ where: { clubId } });
if (!tournaments || tournaments.length === 0) return res.status(200).json([]);
const tournamentIds = tournaments.map(t => t.id);
const rows = await OfficialCompetitionMember.findAll({
where: { tournamentId: { [Op.in]: tournamentIds }, participated: true },
include: [
{ model: OfficialCompetition, as: 'competition', attributes: ['id', 'tournamentId', 'ageClassCompetition', 'startTime'] },
{ model: OfficialTournament, as: 'tournament', attributes: ['id', 'title', 'eventDate'] },
{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] },
]
});
const parseDmy = (s) => {
if (!s) return null;
const m = String(s).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;
};
const fmtDmy = (d) => {
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}`;
};
const byTournament = new Map();
for (const r of rows) {
const t = r.tournament;
const c = r.competition;
const m = r.member;
if (!t || !c || !m) continue;
if (!byTournament.has(t.id)) {
byTournament.set(t.id, {
tournamentId: String(t.id),
title: t.title || null,
startDate: null,
endDate: null,
entries: [],
_dates: [],
_eventDate: t.eventDate || null,
});
}
const bucket = byTournament.get(t.id);
const compDate = parseDmy(c.startTime || '') || null;
if (compDate) bucket._dates.push(compDate);
bucket.entries.push({
memberId: m.id,
memberName: `${m.firstName || ''} ${m.lastName || ''}`.trim(),
competitionId: c.id,
competitionName: c.ageClassCompetition || '',
placement: r.placement || null,
date: compDate ? fmtDmy(compDate) : null,
});
}
const out = [];
for (const t of tournaments) {
const bucket = byTournament.get(t.id) || {
tournamentId: String(t.id),
title: t.title || null,
startDate: null,
endDate: null,
entries: [],
_dates: [],
_eventDate: t.eventDate || null,
};
// Ableiten Start/Ende
if (bucket._dates.length) {
bucket._dates.sort((a, b) => a - b);
bucket.startDate = fmtDmy(bucket._dates[0]);
bucket.endDate = fmtDmy(bucket._dates[bucket._dates.length - 1]);
} else if (bucket._eventDate) {
const all = String(bucket._eventDate).match(/(\d{1,2}\.\d{1,2}\.\d{4})/g) || [];
if (all.length >= 1) {
const d1 = parseDmy(all[0]);
const d2 = all.length >= 2 ? parseDmy(all[1]) : d1;
if (d1) bucket.startDate = fmtDmy(d1);
if (d2) bucket.endDate = fmtDmy(d2);
}
}
// Sort entries: Mitglied, dann Konkurrenz
bucket.entries.sort((a, b) => {
const mcmp = (a.memberName || '').localeCompare(b.memberName || '', 'de', { sensitivity: 'base' });
if (mcmp !== 0) return mcmp;
return (a.competitionName || '').localeCompare(b.competitionName || '', 'de', { sensitivity: 'base' });
});
delete bucket._dates;
delete bucket._eventDate;
out.push(bucket);
}
res.status(200).json(out);
} catch (e) {
res.status(500).json({ error: 'Failed to list club participations' });
}
};
export const deleteOfficialTournament = async (req, res) => {
try {
const { authcode: userToken } = req.headers;

View File

@@ -1,7 +1,7 @@
import express from 'express';
import multer from 'multer';
import { authenticate } from '../middleware/authMiddleware.js';
import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, upsertCompetitionMember } from '../controllers/officialTournamentController.js';
import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, upsertCompetitionMember, listClubParticipations } from '../controllers/officialTournamentController.js';
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
@@ -9,6 +9,7 @@ const upload = multer({ storage: multer.memoryStorage() });
router.use(authenticate);
router.get('/:clubId', listOfficialTournaments);
router.get('/:clubId/participations/summary', listClubParticipations);
router.post('/:clubId/upload', upload.single('pdf'), uploadTournamentPdf);
router.get('/:clubId/:id', getParsedTournament);
router.delete('/:clubId/:id', deleteOfficialTournament);

View File

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