Fügt das Modell OfficialCompetitionMember hinzu und implementiert die Logik zur Verwaltung der Teilnahme von Mitgliedern an offiziellen Wettbewerben. Aktualisiert die Routen und Controller, um die Teilnahmeinformationen zu speichern und abzurufen. Ergänzt die Benutzeroberfläche in OfficialTournaments.vue zur Anzeige und Bearbeitung der Teilnahmeoptionen für Mitglieder.

This commit is contained in:
Torsten Schulz (local)
2025-09-11 12:58:56 +02:00
parent 4a6d868820
commit df02e48cfd
8 changed files with 258 additions and 41 deletions

View File

@@ -4,6 +4,7 @@ const pdfParse = require('pdf-parse/lib/pdf-parse.js');
import { checkAccess } from '../utils/userUtils.js';
import OfficialTournament from '../models/OfficialTournament.js';
import OfficialCompetition from '../models/OfficialCompetition.js';
import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js';
// In-Memory Store (einfacher Start); später DB-Modell
const parsedTournaments = new Map(); // key: id, value: { id, clubId, rawText, parsedData }
@@ -67,6 +68,7 @@ export const getParsedTournament = async (req, res) => {
const t = await OfficialTournament.findOne({ where: { id, clubId } });
if (!t) return res.status(404).json({ error: 'not found' });
const comps = await OfficialCompetition.findAll({ where: { tournamentId: id } });
const entries = await OfficialCompetitionMember.findAll({ where: { tournamentId: id } });
const competitions = comps.map((c) => {
const j = c.toJSON();
return {
@@ -109,12 +111,53 @@ export const getParsedTournament = async (req, res) => {
meldeschluesse: JSON.parse(t.registrationDeadlines || '[]'),
competitions,
},
participation: entries.map(e => ({
id: e.id,
tournamentId: e.tournamentId,
competitionId: e.competitionId,
memberId: e.memberId,
wants: !!e.wants,
registered: !!e.registered,
participated: !!e.participated,
placement: e.placement || null,
})),
});
} catch (e) {
res.status(500).json({ error: 'Failed to fetch parsed tournament' });
}
};
export const upsertCompetitionMember = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, id } = req.params; // id = tournamentId
await checkAccess(userToken, clubId);
const { competitionId, memberId, wants, registered, participated, placement } = req.body;
if (!competitionId || !memberId) return res.status(400).json({ error: 'competitionId and memberId required' });
const [row] = await OfficialCompetitionMember.findOrCreate({
where: { competitionId, memberId },
defaults: {
tournamentId: id,
competitionId,
memberId,
wants: !!wants,
registered: !!registered,
participated: !!participated,
placement: placement || null,
}
});
row.wants = wants !== undefined ? !!wants : row.wants;
row.registered = registered !== undefined ? !!registered : row.registered;
row.participated = participated !== undefined ? !!participated : row.participated;
if (placement !== undefined) row.placement = placement;
await row.save();
return res.status(200).json({ success: true, id: row.id });
} catch (e) {
console.error('[upsertCompetitionMember] Error:', e);
res.status(500).json({ error: 'Failed to save participation' });
}
};
export const listOfficialTournaments = async (req, res) => {
try {
const { authcode: userToken } = req.headers;

View File

@@ -0,0 +1,25 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const OfficialCompetitionMember = sequelize.define('OfficialCompetitionMember', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
tournamentId: { type: DataTypes.INTEGER, allowNull: false },
competitionId: { type: DataTypes.INTEGER, allowNull: false },
memberId: { type: DataTypes.INTEGER, allowNull: false },
wants: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
registered: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
participated: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
placement: { type: DataTypes.STRING, allowNull: true },
}, {
tableName: 'official_competition_members',
timestamps: true,
underscored: true,
indexes: [
{ unique: true, fields: ['competition_id', 'member_id'] },
{ fields: ['tournament_id'] },
],
});
export default OfficialCompetitionMember;

View File

@@ -32,9 +32,17 @@ import Accident from './Accident.js';
import UserToken from './UserToken.js';
import OfficialTournament from './OfficialTournament.js';
import OfficialCompetition from './OfficialCompetition.js';
import OfficialCompetitionMember from './OfficialCompetitionMember.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
// Official competition participations
OfficialCompetition.hasMany(OfficialCompetitionMember, { foreignKey: 'competitionId', as: 'members' });
OfficialCompetitionMember.belongsTo(OfficialCompetition, { foreignKey: 'competitionId', as: 'competition' });
OfficialTournament.hasMany(OfficialCompetitionMember, { foreignKey: 'tournamentId', as: 'competitionMembers' });
OfficialCompetitionMember.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
Member.hasMany(OfficialCompetitionMember, { foreignKey: 'memberId', as: 'officialCompetitionEntries' });
OfficialCompetitionMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
User.hasMany(Log, { foreignKey: 'userId' });
Log.belongsTo(User, { foreignKey: 'userId' });
@@ -230,4 +238,5 @@ export {
UserToken,
OfficialTournament,
OfficialCompetition,
OfficialCompetitionMember,
};

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 } from '../controllers/officialTournamentController.js';
import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, upsertCompetitionMember } from '../controllers/officialTournamentController.js';
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
@@ -12,6 +12,7 @@ router.get('/:clubId', listOfficialTournaments);
router.post('/:clubId/upload', upload.single('pdf'), uploadTournamentPdf);
router.get('/:clubId/:id', getParsedTournament);
router.delete('/:clubId/:id', deleteOfficialTournament);
router.post('/:clubId/:id/participation', upsertCompetitionMember);
export default router;

View File

@@ -8,7 +8,7 @@ import {
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -117,41 +117,60 @@ app.get('*', (req, res) => {
await renameColumnIfExists('official_tournaments', 'konkurrenztypen', 'competition_types', 'TEXT NULL');
await renameColumnIfExists('official_tournaments', 'meldeschluesse', 'registration_deadlines', 'TEXT NULL');
await User.sync();
await Club.sync({ alter: true });
await UserClub.sync({ alter: true });
await Log.sync({ alter: true });
await Member.sync({ alter: true });
await DiaryDate.sync({ alter: true });
await Participant.sync({ alter: true });
await Activity.sync({ alter: true });
await MemberNote.sync({ alter: true });
await DiaryNote.sync({ alter: true });
await DiaryTag.sync({ alter: true });
await MemberDiaryTag.sync({ alter: true });
await DiaryDateTag.sync({ alter: true });
await DiaryMemberTag.sync({ alter: true });
await DiaryMemberNote.sync({ alter: true });
await PredefinedActivity.sync({ alter: true });
await PredefinedActivityImage.sync({ alter: true });
await DiaryDateActivity.sync({ alter: true });
await DiaryMemberActivity.sync({ alter: true });
await OfficialTournament.sync({ alter: true });
await OfficialCompetition.sync({ alter: true });
await Season.sync({ alter: true });
await League.sync({ alter: true });
await Team.sync({ alter: true });
await Location.sync({ alter: true });
await Match.sync({ alter: true });
await Group.sync({ alter: true });
await GroupActivity.sync({ alter: true });
await Tournament.sync({ alter: true });
await TournamentGroup.sync({ alter: true });
await TournamentMember.sync({ alter: true });
await TournamentMatch.sync({ alter: true });
await TournamentResult.sync({ alter: true });
await Accident.sync({ alter: true });
await UserToken.sync();
const isDev = process.env.STAGE === 'dev';
const safeSync = async (model) => {
try {
if (isDev) {
await model.sync({ alter: true });
} else {
await model.sync();
}
} catch (e) {
try {
console.error(`[sync] ${model?.name || 'model'} alter failed:`, e?.message || e);
await model.sync();
} catch (e2) {
console.error(`[sync] fallback failed for ${model?.name || 'model'}:`, e2?.message || e2);
}
}
};
await safeSync(User);
await safeSync(Club);
await safeSync(UserClub);
await safeSync(Log);
await safeSync(Member);
await safeSync(DiaryDate);
await safeSync(Participant);
await safeSync(Activity);
await safeSync(MemberNote);
await safeSync(DiaryNote);
await safeSync(DiaryTag);
await safeSync(MemberDiaryTag);
await safeSync(DiaryDateTag);
await safeSync(DiaryMemberTag);
await safeSync(DiaryMemberNote);
await safeSync(PredefinedActivity);
await safeSync(PredefinedActivityImage);
await safeSync(DiaryDateActivity);
await safeSync(DiaryMemberActivity);
await safeSync(OfficialTournament);
await safeSync(OfficialCompetition);
await safeSync(OfficialCompetitionMember);
await safeSync(Season);
await safeSync(League);
await safeSync(Team);
await safeSync(Location);
await safeSync(Match);
await safeSync(Group);
await safeSync(GroupActivity);
await safeSync(Tournament);
await safeSync(TournamentGroup);
await safeSync(TournamentMember);
await safeSync(TournamentMatch);
await safeSync(TournamentResult);
await safeSync(Accident);
await safeSync(UserToken);
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);

View File

@@ -50,7 +50,7 @@
</a>
<a href="/tournaments" class="nav-link">
<span class="nav-icon">🏆</span>
Turniere
Interne Turniere
</a>
<a href="/official-tournaments" class="nav-link">
<span class="nav-icon">📄</span>

View File

@@ -264,7 +264,7 @@ class PDFGenerator {
});
}
addMemberCompetitions(tournamentTitle, memberName, recommendedRows = [], otherRows = []) {
addMemberCompetitions(tournamentTitle, memberName, recommendedRows = [], otherRows = [], venues = []) {
let y = this.margin;
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
@@ -319,6 +319,37 @@ class PDFGenerator {
}
}
}
// Austragungsort(e) direkt vor den Hinweisen
const venueLines = Array.isArray(venues) ? venues.filter(Boolean) : [];
if (venueLines.length) {
const heading = venueLines.length === 1 ? 'Austragungsort' : 'Austragungsorte';
const maxWidth = 210 - this.margin * 2;
if (y + 20 + venueLines.length * 6 > this.pageHeight) {
this.addNewPage();
y = this.margin;
} else {
y += 6;
}
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(13);
this.pdf.text(`${heading}:`, this.margin, y);
y += 7;
this.pdf.setFont('helvetica', 'normal');
this.pdf.setFontSize(12);
for (const v of venueLines) {
const wrapped = this.pdf.splitTextToSize(String(v), maxWidth);
for (const line of wrapped) {
this.pdf.text(line, this.margin, y);
y += 6;
if (y > this.pageHeight) {
this.addNewPage();
y = this.margin;
this.pdf.setFont('helvetica', 'normal');
this.pdf.setFontSize(12);
}
}
}
}
// Hinweise-Sektion
const remainingForHints = 60; // Platz für Überschrift + Liste abschätzen
if (y + remainingForHints > this.pageHeight) {

View File

@@ -87,13 +87,42 @@
<th>Name</th>
<th>Geburtsdatum</th>
<th>Alter</th>
<th>Angemeldet</th>
<th>Teilgenommen</th>
<th>Platzierung</th>
</tr>
</thead>
<tbody>
<tr v-for="m in eligibleMembers(c)" :key="m.id">
<td>{{ m.firstName }} {{ m.lastName }}</td>
<td>
<label style="display:flex; align-items:center; gap:.4rem;">
<input type="checkbox"
:checked="getParticipation(c.id, m.id).wants"
@change="onToggleWants(c, m, $event.target.checked)"
title="Teilnahme-Wunsch" />
<span>{{ m.firstName }} {{ m.lastName }}</span>
</label>
</td>
<td>{{ formatDateStr(m.birthDate) }}</td>
<td>{{ ageOnRef(m, c) ?? '' }}</td>
<td>
<input type="checkbox"
:checked="getParticipation(c.id, m.id).registered"
@change="onToggleRegistered(c, m, $event.target.checked)"
title="Angemeldet" />
</td>
<td>
<input type="checkbox"
:checked="getParticipation(c.id, m.id).participated"
@change="onToggleParticipated(c, m, $event.target.checked)"
title="Teilgenommen" />
</td>
<td>
<input type="text" style="width:7rem;"
:value="getParticipation(c.id, m.id).placement || ''"
@change="onPlacementChange(c, m, $event.target.value)"
placeholder="z.B. 3. Platz" />
</td>
</tr>
</tbody>
</table>
@@ -167,6 +196,7 @@ export default {
showMemberDialog: false,
memberRecommendations: {},
selectedMemberIdForDialog: null,
participationMap: {}, // key: `${competitionId}-${memberId}` => { wants, registered, participated, placement }
};
},
computed: {
@@ -211,6 +241,7 @@ export default {
if (!this.uploadedId) return;
const t = await apiClient.get(`/official-tournaments/${this.currentClub}/${this.uploadedId}`);
this.parsed = t.data;
this.buildParticipationMap(t.data && t.data.participation ? t.data.participation : []);
// Mitglieder laden (alle aktiv)
const m = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
this.members = m.data;
@@ -219,6 +250,61 @@ export default {
const r = await apiClient.get(`/official-tournaments/${this.currentClub}`);
this.list = r.data;
},
buildParticipationMap(entries) {
const map = {};
for (const e of entries) {
const key = `${e.competitionId}-${e.memberId}`;
map[key] = {
wants: !!e.wants,
registered: !!e.registered,
participated: !!e.participated,
placement: e.placement || null,
};
}
this.participationMap = map;
},
getParticipation(competitionId, memberId) {
const key = `${competitionId}-${memberId}`;
if (!this.participationMap[key]) {
this.$set ? this.$set(this.participationMap, key, { wants: false, registered: false, participated: false, placement: null })
: (this.participationMap[key] = { wants: false, registered: false, participated: false, placement: null });
}
return this.participationMap[key];
},
async saveParticipation(competitionId, memberId) {
const key = `${competitionId}-${memberId}`;
const p = this.participationMap[key] || { wants: false, registered: false, participated: false, placement: null };
try {
await apiClient.post(`/official-tournaments/${this.currentClub}/${this.uploadedId}/participation`, {
competitionId,
memberId,
wants: !!p.wants,
registered: !!p.registered,
participated: !!p.participated,
placement: p.placement || null,
});
} catch (e) {
// optional: Fehleranzeige
}
},
onToggleWants(c, m, val) {
const key = `${c.id}-${m.id}`;
this.getParticipation(c.id, m.id).wants = !!val;
this.saveParticipation(c.id, m.id);
},
onToggleRegistered(c, m, val) {
this.getParticipation(c.id, m.id).registered = !!val;
this.saveParticipation(c.id, m.id);
},
onToggleParticipated(c, m, val) {
this.getParticipation(c.id, m.id).participated = !!val;
this.saveParticipation(c.id, m.id);
},
onPlacementChange(c, m, val) {
const v = (val || '').trim();
this.getParticipation(c.id, m.id).placement = v || null;
this.saveParticipation(c.id, m.id);
},
// Auswahl Helfer + PDF-Generierung
openMemberDialog() { this.showMemberDialog = true; },
closeMemberDialog() { this.showMemberDialog = false; },
@@ -274,7 +360,10 @@ export default {
const otherRows = rows.filter(r => !recKeys.includes(r.key));
if (!first) pdf.addNewPage();
first = false;
pdf.addMemberCompetitions(title, `${m.firstName} ${m.lastName}`, recRows, otherRows);
const venues = (this.parsed && this.parsed.parsedData && Array.isArray(this.parsed.parsedData.austragungsorte))
? this.parsed.parsedData.austragungsorte
: [];
pdf.addMemberCompetitions(title, `${m.firstName} ${m.lastName}`, recRows, otherRows, venues);
}
pdf.save('turnier_mitglieder.pdf');
},