diff --git a/backend/controllers/officialTournamentController.js b/backend/controllers/officialTournamentController.js
index feb6281..cf88a4f 100644
--- a/backend/controllers/officialTournamentController.js
+++ b/backend/controllers/officialTournamentController.js
@@ -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;
diff --git a/backend/routes/officialTournamentRoutes.js b/backend/routes/officialTournamentRoutes.js
index e2fad46..2a3c012 100644
--- a/backend/routes/officialTournamentRoutes.js
+++ b/backend/routes/officialTournamentRoutes.js
@@ -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);
diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue
index 0c89b28..64c37d7 100644
--- a/frontend/src/views/OfficialTournaments.vue
+++ b/frontend/src/views/OfficialTournaments.vue
@@ -2,17 +2,22 @@
Offizielle Turniere
-
Gespeicherte Veranstaltungen
-
-
+
+
+
+
+
+
Gespeicherte Veranstaltungen
+
+
@@ -48,6 +53,11 @@
+
+
+
+
+
Konkurrenzen
@@ -62,7 +72,7 @@
|
- |
@@ -133,6 +143,66 @@
+
+
+
Ergebnisse
+
+
+
+ | Mitglied |
+ Konkurrenz |
+ Startzeit |
+ Angemeldet |
+ Teilgenommen |
+ Platzierung |
+
+
+
+
+ | {{ row.memberName }} |
+ {{ row.competitionName }} |
+ {{ row.start }} |
+ {{ row.registered ? 'Ja' : 'Nein' }} |
+ {{ row.participated ? 'Ja' : 'Nein' }} |
+ {{ row.placement || '–' }} |
+
+
+
+
+
+
+
+
Turnierbeteiligungen
+
+
+
+
+
+
+
+ | Mitglied |
+ Konkurrenz |
+ Datum |
+ Platzierung |
+
+
+
+
+ | {{ row.memberName }} |
+ {{ row.competitionName }} |
+ {{ row.date }} |
+ {{ row.placement || '–' }} |
+
+
+
+
@@ -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 {