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:
@@ -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;
|
||||
|
||||
25
backend/models/OfficialCompetitionMember.js
Normal file
25
backend/models/OfficialCompetitionMember.js
Normal 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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user