Compare commits
61 Commits
redesign
...
activitypa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd96f5df1 | ||
|
|
4bfa6a5889 | ||
|
|
f4187512ba | ||
|
|
b557297bf0 | ||
|
|
eb2273e28c | ||
|
|
091599b745 | ||
|
|
d70a5ca63e | ||
|
|
09ffd1db3d | ||
|
|
d90acf43e1 | ||
|
|
adb93af906 | ||
|
|
a36f0ea446 | ||
|
|
e4fcf2eca2 | ||
|
|
0ee16c7766 | ||
|
|
21c19298da | ||
|
|
3c65fed994 | ||
|
|
66046ddccd | ||
|
|
561d8186d3 | ||
|
|
312f8f24ab | ||
|
|
ba4b56360d | ||
|
|
02732a01da | ||
|
|
4307fa7d82 | ||
|
|
a1dc6afb2c | ||
|
|
92ce64b807 | ||
|
|
296939d1a0 | ||
|
|
dc8a5778d6 | ||
|
|
cf04e5bfe8 | ||
|
|
ace15ae1d3 | ||
|
|
d4b82a3a6f | ||
|
|
48cd0921df | ||
|
|
df02e48cfd | ||
|
|
4a6d868820 | ||
|
|
52556a4292 | ||
|
|
3a02ffb3e3 | ||
|
|
c4b9a7d782 | ||
|
|
5e8b221541 | ||
|
|
26720c8df3 | ||
|
|
a1ab742126 | ||
|
|
f21ad3d8a3 | ||
|
|
51d3087006 | ||
|
|
a08588a075 | ||
|
|
5d67a52b45 | ||
|
|
f29425c987 | ||
|
|
e3b8488d2b | ||
|
|
f49e1896b9 | ||
|
|
2092473cf3 | ||
|
|
c00849a154 | ||
|
|
8069946154 | ||
|
|
975800c1ab | ||
|
|
b82a80a11d | ||
|
|
244b61c901 | ||
|
|
c7325ac982 | ||
|
|
8fbdc68016 | ||
|
|
455b2c94cd | ||
|
|
c9a1026b50 | ||
|
|
f6f1ea0403 | ||
|
|
a636b32510 | ||
|
|
8ee1203ec6 | ||
|
|
bce5150757 | ||
|
|
117f6b4c93 | ||
|
|
6a8b0e35d7 | ||
|
|
ed96fc5f27 |
@@ -116,3 +116,15 @@ const deleteTagFromDiaryDate = async (req, res) => {
|
||||
|
||||
export { getDatesForClub, createDateForClub, updateTrainingTimes, addDiaryNote, deleteDiaryNote, addDiaryTag,
|
||||
addTagToDiaryDate, deleteTagFromDiaryDate };
|
||||
|
||||
export const deleteDateForClub = async (req, res) => {
|
||||
try {
|
||||
const { clubId, dateId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await diaryService.removeDateForClub(userToken, clubId, dateId);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[deleteDateForClub] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'systemerror' });
|
||||
}
|
||||
};
|
||||
|
||||
52
backend/controllers/diaryMemberActivityController.js
Normal file
52
backend/controllers/diaryMemberActivityController.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import DiaryMemberActivity from '../models/DiaryMemberActivity.js';
|
||||
import Participant from '../models/Participant.js';
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
|
||||
export const getMembersForActivity = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, diaryDateActivityId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const list = await DiaryMemberActivity.findAll({ where: { diaryDateActivityId } });
|
||||
res.status(200).json(list);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Error fetching members for activity' });
|
||||
}
|
||||
};
|
||||
|
||||
export const addMembersToActivity = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, diaryDateActivityId } = req.params;
|
||||
const { participantIds } = req.body; // array of participant ids
|
||||
await checkAccess(userToken, clubId);
|
||||
const validParticipants = await Participant.findAll({ where: { id: participantIds } });
|
||||
const validIds = new Set(validParticipants.map(p => p.id));
|
||||
const created = [];
|
||||
for (const pid of participantIds) {
|
||||
if (!validIds.has(pid)) continue;
|
||||
const existing = await DiaryMemberActivity.findOne({ where: { diaryDateActivityId, participantId: pid } });
|
||||
if (!existing) {
|
||||
const rec = await DiaryMemberActivity.create({ diaryDateActivityId, participantId: pid });
|
||||
created.push(rec);
|
||||
}
|
||||
}
|
||||
res.status(201).json(created);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Error adding members to activity' });
|
||||
}
|
||||
};
|
||||
|
||||
export const removeMemberFromActivity = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, diaryDateActivityId, participantId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
await DiaryMemberActivity.destroy({ where: { diaryDateActivityId, participantId } });
|
||||
res.status(200).json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Error removing member from activity' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@ const getWaitingApprovals = async(req, res) => {
|
||||
const setClubMembers = async (req, res) => {
|
||||
try {
|
||||
const { id: memberId, firstname: firstName, lastname: lastName, street, city, birthdate, phone, email, active,
|
||||
testMembership, picsInInternetAllowed } = req.body;
|
||||
testMembership, picsInInternetAllowed, gender } = req.body;
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, birthdate,
|
||||
phone, email, active, testMembership, picsInInternetAllowed);
|
||||
phone, email, active, testMembership, picsInInternetAllowed, gender);
|
||||
res.status(addResult.status || 500).json(addResult.response);
|
||||
} catch (error) {
|
||||
console.error('[setClubMembers] - Error:', error);
|
||||
|
||||
619
backend/controllers/officialTournamentController.js
Normal file
619
backend/controllers/officialTournamentController.js
Normal file
@@ -0,0 +1,619 @@
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
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';
|
||||
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 }
|
||||
let seq = 1;
|
||||
|
||||
export const uploadTournamentPdf = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'No pdf provided' });
|
||||
const data = await pdfParse(req.file.buffer);
|
||||
const parsed = parseTournamentText(data.text);
|
||||
const t = await OfficialTournament.create({
|
||||
clubId,
|
||||
title: parsed.title || null,
|
||||
eventDate: parsed.termin || null,
|
||||
organizer: null,
|
||||
host: null,
|
||||
venues: JSON.stringify(parsed.austragungsorte || []),
|
||||
competitionTypes: JSON.stringify(parsed.konkurrenztypen || []),
|
||||
registrationDeadlines: JSON.stringify(parsed.meldeschluesse || []),
|
||||
entryFees: JSON.stringify(parsed.entryFees || {}),
|
||||
});
|
||||
// competitions persistieren
|
||||
for (const c of parsed.competitions || []) {
|
||||
// Korrigiere Fehlzuordnung: Wenn die Zeile mit "Stichtag" fälschlich in performanceClass steht
|
||||
let performanceClass = c.leistungsklasse || c.performanceClass || null;
|
||||
let cutoffDate = c.stichtag || c.cutoffDate || null;
|
||||
if (performanceClass && /^stichtag\b/i.test(performanceClass)) {
|
||||
cutoffDate = performanceClass.replace(/^stichtag\s*:?\s*/i, '').trim();
|
||||
performanceClass = null;
|
||||
}
|
||||
await OfficialCompetition.create({
|
||||
tournamentId: t.id,
|
||||
ageClassCompetition: c.altersklasseWettbewerb || c.ageClassCompetition || null,
|
||||
performanceClass,
|
||||
startTime: c.startzeit || c.startTime || null,
|
||||
registrationDeadlineDate: c.meldeschlussDatum || c.registrationDeadlineDate || null,
|
||||
registrationDeadlineOnline: c.meldeschlussOnline || c.registrationDeadlineOnline || null,
|
||||
cutoffDate,
|
||||
ttrRelevant: c.ttrRelevant || null,
|
||||
openTo: c.offenFuer || c.openTo || null,
|
||||
preliminaryRound: c.vorrunde || c.preliminaryRound || null,
|
||||
finalRound: c.endrunde || c.finalRound || null,
|
||||
maxParticipants: c.maxTeilnehmer || c.maxParticipants || null,
|
||||
entryFee: c.startgeld || c.entryFee || null,
|
||||
});
|
||||
}
|
||||
res.status(201).json({ id: String(t.id) });
|
||||
} catch (e) {
|
||||
console.error('[uploadTournamentPdf] Error:', e);
|
||||
res.status(500).json({ error: 'Failed to parse pdf' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getParsedTournament = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
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 {
|
||||
id: j.id,
|
||||
tournamentId: j.tournamentId,
|
||||
ageClassCompetition: j.ageClassCompetition || null,
|
||||
performanceClass: j.performanceClass || null,
|
||||
startTime: j.startTime || null,
|
||||
registrationDeadlineDate: j.registrationDeadlineDate || null,
|
||||
registrationDeadlineOnline: j.registrationDeadlineOnline || null,
|
||||
cutoffDate: j.cutoffDate || null,
|
||||
ttrRelevant: j.ttrRelevant || null,
|
||||
openTo: j.openTo || null,
|
||||
preliminaryRound: j.preliminaryRound || null,
|
||||
finalRound: j.finalRound || null,
|
||||
maxParticipants: j.maxParticipants || null,
|
||||
entryFee: j.entryFee || null,
|
||||
// Legacy Felder zusätzlich, falls Frontend sie noch nutzt
|
||||
altersklasseWettbewerb: j.ageClassCompetition || null,
|
||||
leistungsklasse: j.performanceClass || null,
|
||||
startzeit: j.startTime || null,
|
||||
meldeschlussDatum: j.registrationDeadlineDate || null,
|
||||
meldeschlussOnline: j.registrationDeadlineOnline || null,
|
||||
stichtag: j.cutoffDate || null,
|
||||
offenFuer: j.openTo || null,
|
||||
vorrunde: j.preliminaryRound || null,
|
||||
endrunde: j.finalRound || null,
|
||||
maxTeilnehmer: j.maxParticipants || null,
|
||||
startgeld: j.entryFee || null,
|
||||
};
|
||||
});
|
||||
res.status(200).json({
|
||||
id: String(t.id),
|
||||
clubId: String(t.clubId),
|
||||
parsedData: {
|
||||
title: t.title,
|
||||
termin: t.eventDate,
|
||||
austragungsorte: JSON.parse(t.venues || '[]'),
|
||||
konkurrenztypen: JSON.parse(t.competitionTypes || '[]'),
|
||||
meldeschluesse: JSON.parse(t.registrationDeadlines || '[]'),
|
||||
entryFees: JSON.parse(t.entryFees || '{}'),
|
||||
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 updateParticipantStatus = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params; // id = tournamentId
|
||||
await checkAccess(userToken, clubId);
|
||||
const { competitionId, memberId, action } = req.body;
|
||||
|
||||
if (!competitionId || !memberId || !action) {
|
||||
return res.status(400).json({ error: 'competitionId, memberId and action required' });
|
||||
}
|
||||
|
||||
const [row] = await OfficialCompetitionMember.findOrCreate({
|
||||
where: { competitionId, memberId },
|
||||
defaults: {
|
||||
tournamentId: id,
|
||||
competitionId,
|
||||
memberId,
|
||||
wants: false,
|
||||
registered: false,
|
||||
participated: false,
|
||||
placement: null,
|
||||
}
|
||||
});
|
||||
|
||||
// Status-Update basierend auf Aktion
|
||||
switch (action) {
|
||||
case 'register':
|
||||
// Von "möchte teilnehmen" zu "angemeldet"
|
||||
row.wants = true;
|
||||
row.registered = true;
|
||||
row.participated = false;
|
||||
break;
|
||||
case 'participate':
|
||||
// Von "angemeldet" zu "hat gespielt"
|
||||
row.wants = true;
|
||||
row.registered = true;
|
||||
row.participated = true;
|
||||
break;
|
||||
case 'reset':
|
||||
// Zurück zu "möchte teilnehmen"
|
||||
row.wants = true;
|
||||
row.registered = false;
|
||||
row.participated = false;
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: 'Invalid action. Use: register, participate, or reset' });
|
||||
}
|
||||
|
||||
await row.save();
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
id: row.id,
|
||||
status: {
|
||||
wants: row.wants,
|
||||
registered: row.registered,
|
||||
participated: row.participated,
|
||||
placement: row.placement
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[updateParticipantStatus] Error:', e);
|
||||
res.status(500).json({ error: 'Failed to update participant status' });
|
||||
}
|
||||
};
|
||||
|
||||
export const listOfficialTournaments = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const list = await OfficialTournament.findAll({ where: { clubId } });
|
||||
res.status(200).json(list);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to list tournaments' });
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
||||
if (!t) return res.status(404).json({ error: 'not found' });
|
||||
await OfficialCompetition.destroy({ where: { tournamentId: id } });
|
||||
await OfficialTournament.destroy({ where: { id } });
|
||||
res.status(204).send();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to delete tournament' });
|
||||
}
|
||||
};
|
||||
|
||||
function parseTournamentText(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const normLines = lines.map(l => l.replace(/\s+/g, ' ').trim());
|
||||
|
||||
const findTitle = () => {
|
||||
const idx = normLines.findIndex(l => /Kreiseinzelmeisterschaften/i.test(l));
|
||||
return idx >= 0 ? normLines[idx] : null;
|
||||
};
|
||||
|
||||
// Neue Funktion: Teilnahmegebühren pro Spielklasse extrahieren
|
||||
const extractEntryFees = () => {
|
||||
const entryFees = {};
|
||||
|
||||
// Verschiedene Patterns für Teilnahmegebühren suchen
|
||||
const feePatterns = [
|
||||
// Pattern 1: "Startgeld: U12: 5€, U14: 7€, U16: 10€"
|
||||
/startgeld\s*:?\s*(.+)/i,
|
||||
// Pattern 2: "Teilnahmegebühr: U12: 5€, U14: 7€"
|
||||
/teilnahmegebühr\s*:?\s*(.+)/i,
|
||||
// Pattern 3: "Gebühr: U12: 5€, U14: 7€"
|
||||
/gebühr\s*:?\s*(.+)/i,
|
||||
// Pattern 4: "Einschreibegebühr: U12: 5€, U14: 7€"
|
||||
/einschreibegebühr\s*:?\s*(.+)/i,
|
||||
// Pattern 5: "Anmeldegebühr: U12: 5€, U14: 7€"
|
||||
/anmeldegebühr\s*:?\s*(.+)/i
|
||||
];
|
||||
|
||||
for (const pattern of feePatterns) {
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const line = normLines[i];
|
||||
const match = line.match(pattern);
|
||||
if (match) {
|
||||
const feeText = match[1];
|
||||
|
||||
// Extrahiere Gebühren aus dem Text
|
||||
// Unterstützt verschiedene Formate:
|
||||
// "U12: 5€, U14: 7€, U16: 10€"
|
||||
// "U12: 5 Euro, U14: 7 Euro"
|
||||
// "U12 5€, U14 7€"
|
||||
// "U12: 5,00€, U14: 7,00€"
|
||||
const feeMatches = feeText.matchAll(/(U\d+|AK\s*\d+)\s*:?\s*(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/gi);
|
||||
|
||||
for (const feeMatch of feeMatches) {
|
||||
const ageClass = feeMatch[1].toUpperCase().replace(/\s+/g, '');
|
||||
const amount = feeMatch[2].replace(',', '.');
|
||||
const numericAmount = parseFloat(amount);
|
||||
|
||||
if (!isNaN(numericAmount)) {
|
||||
entryFees[ageClass] = {
|
||||
amount: numericAmount,
|
||||
currency: '€',
|
||||
rawText: feeMatch[0]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn wir Gebühren gefunden haben, brechen wir ab
|
||||
if (Object.keys(entryFees).length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(entryFees).length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return entryFees;
|
||||
};
|
||||
|
||||
const extractBlockAfter = (labels, multiline = false) => {
|
||||
const idx = normLines.findIndex(l => labels.some(lb => l.toLowerCase().startsWith(lb)));
|
||||
if (idx === -1) return multiline ? [] : null;
|
||||
const line = normLines[idx];
|
||||
const afterColon = line.includes(':') ? line.split(':').slice(1).join(':').trim() : '';
|
||||
if (!multiline) {
|
||||
if (afterColon) return afterColon;
|
||||
// sonst nächste nicht-leere Zeile
|
||||
for (let i = idx + 1; i < normLines.length; i++) {
|
||||
if (normLines[i]) return normLines[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// multiline bis zur nächsten Leerzeile oder nächsten bekannten Section
|
||||
const out = [];
|
||||
if (afterColon) out.push(afterColon);
|
||||
for (let i = idx + 1; i < normLines.length; i++) {
|
||||
const ln = normLines[i];
|
||||
if (!ln) break;
|
||||
if (/^(termin|austragungsort|austragungsorte|konkurrenz|konkurrenzen|konkurrenztypen|meldeschluss|altersklassen|startzeiten)/i.test(ln)) break;
|
||||
out.push(ln);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const extractAllMatches = (regex) => {
|
||||
const results = [];
|
||||
for (const l of normLines) {
|
||||
const m = l.match(regex);
|
||||
if (m) results.push(m);
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const title = findTitle();
|
||||
const termin = extractBlockAfter(['termin', 'termin '], false);
|
||||
const austragungsorte = extractBlockAfter(['austragungsort', 'austragungsorte'], true);
|
||||
let konkurrenzRaw = extractBlockAfter(['konkurrenz', 'konkurrenzen', 'konkurrenztypen'], true);
|
||||
if (konkurrenzRaw && !Array.isArray(konkurrenzRaw)) konkurrenzRaw = [konkurrenzRaw];
|
||||
const konkurrenztypen = (konkurrenzRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean);
|
||||
|
||||
// Meldeschlüsse mit Position und Zuordnung zu AK ermitteln
|
||||
const meldeschluesseRaw = [];
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const l = normLines[i];
|
||||
const m = l.match(/meldeschluss\s*:?\s*(.+)$/i);
|
||||
if (m) meldeschluesseRaw.push({ line: i, value: m[1].trim() });
|
||||
}
|
||||
|
||||
let altersRaw = extractBlockAfter(['altersklassen', 'altersklasse'], true);
|
||||
if (altersRaw && !Array.isArray(altersRaw)) altersRaw = [altersRaw];
|
||||
const altersklassen = (altersRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean);
|
||||
|
||||
// Wettbewerbe/Konkurrenzen parsen (Block ab "3. Konkurrenzen")
|
||||
const competitions = [];
|
||||
const konkIdx = normLines.findIndex(l => /^\s*3\.?\s+Konkurrenzen/i.test(l) || /^Konkurrenzen\b/i.test(l));
|
||||
// Bestimme Start-Sektionsnummer (z. B. 3 bei "3. Konkurrenzen"), fallback 3
|
||||
const startSectionNum = (() => {
|
||||
if (konkIdx === -1) return 3;
|
||||
const m = normLines[konkIdx].match(/^\s*(\d+)\./);
|
||||
return m ? parseInt(m[1], 10) : 3;
|
||||
})();
|
||||
const nextSectionIdx = () => {
|
||||
for (let i = konkIdx + 1; i < normLines.length; i++) {
|
||||
const m = normLines[i].match(/^\s*(\d+)\.\s+/);
|
||||
if (m) {
|
||||
const num = parseInt(m[1], 10);
|
||||
if (!Number.isNaN(num) && num > startSectionNum) return i;
|
||||
}
|
||||
// Hinweis: Seitenfußzeilen wie "nu.Dokument ..." ignorieren wir, damit mehrseitige Blöcke nicht abbrechen
|
||||
}
|
||||
return normLines.length;
|
||||
};
|
||||
if (konkIdx !== -1) {
|
||||
const endIdx = nextSectionIdx();
|
||||
let i = konkIdx + 1;
|
||||
while (i < endIdx) {
|
||||
const line = normLines[i];
|
||||
if (/^Altersklasse\/Wettbewerb\s*:/i.test(line)) {
|
||||
const comp = {};
|
||||
comp.altersklasseWettbewerb = line.split(':').slice(1).join(':').trim();
|
||||
i++;
|
||||
while (i < endIdx && !/^Altersklasse\/Wettbewerb\s*:/i.test(normLines[i])) {
|
||||
const ln = normLines[i];
|
||||
const m = ln.match(/^([^:]+):\s*(.*)$/);
|
||||
if (m) {
|
||||
const key = m[1].trim().toLowerCase();
|
||||
const val = m[2].trim();
|
||||
if (key.startsWith('leistungsklasse')) comp.leistungsklasse = val;
|
||||
else if (key === 'startzeit') {
|
||||
// Erwartet: 20.09.2025 13:30 Uhr -> wir extrahieren Datum+Zeit
|
||||
const sm = val.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{1,2}:\d{2})/);
|
||||
comp.startzeit = sm ? `${sm[1]} ${sm[2]}` : val;
|
||||
}
|
||||
else if (key.startsWith('meldeschluss datum')) comp.meldeschlussDatum = val;
|
||||
else if (key.startsWith('meldeschluss online')) comp.meldeschlussOnline = val;
|
||||
else if (key === 'stichtag') comp.stichtag = val;
|
||||
else if (key === 'ttr-relevant') comp.ttrRelevant = val;
|
||||
else if (key === 'offen für') comp.offenFuer = val;
|
||||
else if (key.startsWith('austragungssys. vorrunde')) comp.vorrunde = val;
|
||||
else if (key.startsWith('austragungssys. endrunde')) comp.endrunde = val;
|
||||
else if (key.startsWith('max. teilnehmerzahl')) comp.maxTeilnehmer = val;
|
||||
else if (key === 'startgeld') {
|
||||
comp.startgeld = val;
|
||||
// Versuche auch spezifische Gebühren für diese Altersklasse zu extrahieren
|
||||
const ageClassMatch = comp.altersklasseWettbewerb?.match(/(U\d+|AK\s*\d+)/i);
|
||||
if (ageClassMatch) {
|
||||
const ageClass = ageClassMatch[1].toUpperCase().replace(/\s+/g, '');
|
||||
const feeMatch = val.match(/(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/);
|
||||
if (feeMatch) {
|
||||
const amount = feeMatch[1].replace(',', '.');
|
||||
const numericAmount = parseFloat(amount);
|
||||
if (!isNaN(numericAmount)) {
|
||||
comp.entryFeeDetails = {
|
||||
amount: numericAmount,
|
||||
currency: '€',
|
||||
ageClass: ageClass
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
competitions.push(comp);
|
||||
continue; // schon auf nächster Zeile
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Altersklassen-Positionen im Text (zur Zuordnung von Meldeschlüssen)
|
||||
const akPositions = [];
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const l = normLines[i];
|
||||
const m = l.match(/\b(U\d+|AK\s*\d+)\b/i);
|
||||
if (m) akPositions.push({ line: i, ak: m[1].toUpperCase().replace(/\s+/g, '') });
|
||||
}
|
||||
|
||||
const meldeschluesseByAk = {};
|
||||
for (const ms of meldeschluesseRaw) {
|
||||
// Nächste AK im Umkreis von 3 Zeilen suchen
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
for (const ak of akPositions) {
|
||||
const dist = Math.abs(ak.line - ms.line);
|
||||
if (dist < bestDist && dist <= 3) { best = ak; bestDist = dist; }
|
||||
}
|
||||
if (best) {
|
||||
if (!meldeschluesseByAk[best.ak]) meldeschluesseByAk[best.ak] = new Set();
|
||||
meldeschluesseByAk[best.ak].add(ms.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup global
|
||||
const meldeschluesse = Array.from(new Set(meldeschluesseRaw.map(x => x.value)));
|
||||
// Sets zu Arrays
|
||||
const meldeschluesseByAkOut = Object.fromEntries(Object.entries(meldeschluesseByAk).map(([k,v]) => [k, Array.from(v)]));
|
||||
|
||||
// Vorhandene einfache Personenerkennung (optional, zu Analysezwecken)
|
||||
const entries = [];
|
||||
for (const l of normLines) {
|
||||
const m = l.match(/^([A-Za-zÄÖÜäöüß\-\s']{3,})(?:\s+\((m|w|d)\))?$/i);
|
||||
if (m && /\s/.test(m[1])) {
|
||||
entries.push({ name: m[1].trim(), genderHint: m[2] || null });
|
||||
}
|
||||
}
|
||||
|
||||
// Extrahiere Teilnahmegebühren
|
||||
const entryFees = extractEntryFees();
|
||||
|
||||
return {
|
||||
title,
|
||||
termin,
|
||||
austragungsorte,
|
||||
konkurrenztypen,
|
||||
meldeschluesse,
|
||||
meldeschluesseByAk: meldeschluesseByAkOut,
|
||||
altersklassen,
|
||||
startzeiten: {},
|
||||
competitions,
|
||||
entries,
|
||||
entryFees, // Neue: Teilnahmegebühren pro Spielklasse
|
||||
debug: { normLines },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import predefinedActivityService from '../services/predefinedActivityService.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export const createPredefinedActivity = async (req, res) => {
|
||||
try {
|
||||
const { name, description, durationText, duration } = req.body;
|
||||
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, description, durationText, duration });
|
||||
const { name, code, description, durationText, duration, imageLink, drawingData } = req.body;
|
||||
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink, drawingData });
|
||||
res.status(201).json(predefinedActivity);
|
||||
} catch (error) {
|
||||
console.error('[createPredefinedActivity] - Error:', error);
|
||||
@@ -25,10 +28,11 @@ export const getPredefinedActivityById = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const predefinedActivity = await predefinedActivityService.getPredefinedActivityById(id);
|
||||
const images = await PredefinedActivityImage.findAll({ where: { predefinedActivityId: id } });
|
||||
if (!predefinedActivity) {
|
||||
return res.status(404).json({ error: 'Predefined activity not found' });
|
||||
}
|
||||
res.status(200).json(predefinedActivity);
|
||||
res.status(200).json({ ...predefinedActivity.toJSON(), images });
|
||||
} catch (error) {
|
||||
console.error('[getPredefinedActivityById] - Error:', error);
|
||||
res.status(500).json({ error: 'Error fetching predefined activity' });
|
||||
@@ -38,11 +42,43 @@ export const getPredefinedActivityById = async (req, res) => {
|
||||
export const updatePredefinedActivity = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, durationText, duration } = req.body;
|
||||
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, description, durationText, duration });
|
||||
const { name, code, description, durationText, duration, imageLink, drawingData } = req.body;
|
||||
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink, drawingData });
|
||||
res.status(200).json(updatedActivity);
|
||||
} catch (error) {
|
||||
console.error('[updatePredefinedActivity] - Error:', error);
|
||||
res.status(500).json({ error: 'Error updating predefined activity' });
|
||||
}
|
||||
};
|
||||
|
||||
export const searchPredefinedActivities = async (req, res) => {
|
||||
try {
|
||||
const { q, limit } = req.query;
|
||||
const result = await predefinedActivityService.searchPredefinedActivities(q, limit);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[searchPredefinedActivities] - Error:', error);
|
||||
res.status(500).json({ error: 'Error searching predefined activities' });
|
||||
}
|
||||
};
|
||||
|
||||
export const mergePredefinedActivities = async (req, res) => {
|
||||
try {
|
||||
const { sourceId, targetId } = req.body;
|
||||
await predefinedActivityService.mergeActivities(sourceId, targetId);
|
||||
res.status(200).json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[mergePredefinedActivities] - Error:', error);
|
||||
res.status(500).json({ error: 'Error merging predefined activities' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deduplicatePredefinedActivities = async (req, res) => {
|
||||
try {
|
||||
const result = await predefinedActivityService.deduplicateActivities();
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[deduplicatePredefinedActivities] - Error:', error);
|
||||
res.status(500).json({ error: 'Error deduplicating predefined activities' });
|
||||
}
|
||||
};
|
||||
|
||||
97
backend/controllers/predefinedActivityImageController.js
Normal file
97
backend/controllers/predefinedActivityImageController.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import { checkGlobalAccess } from '../utils/userUtils.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const uploadPredefinedActivityImage = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params; // predefinedActivityId
|
||||
const { authcode: userToken } = req.headers;
|
||||
await checkGlobalAccess(userToken); // Predefined Activities sind global, keine Club-Zugriffskontrolle nötig
|
||||
|
||||
const activity = await PredefinedActivity.findByPk(id);
|
||||
if (!activity) {
|
||||
return res.status(404).json({ error: 'Predefined activity not found' });
|
||||
}
|
||||
|
||||
if (!req.file || !req.file.buffer) {
|
||||
return res.status(400).json({ error: 'No image uploaded' });
|
||||
}
|
||||
|
||||
const imagesDir = path.join('images', 'predefined');
|
||||
if (!fs.existsSync(imagesDir)) {
|
||||
fs.mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
|
||||
const fileName = `${id}-${Date.now()}.jpg`;
|
||||
const filePath = path.join(imagesDir, fileName);
|
||||
|
||||
await sharp(req.file.buffer)
|
||||
.resize(800, 800, { fit: 'inside' })
|
||||
.jpeg({ quality: 85 })
|
||||
.toFile(filePath);
|
||||
|
||||
// Extrahiere Zeichnungsdaten aus dem Request
|
||||
const drawingData = req.body.drawingData ? JSON.parse(req.body.drawingData) : null;
|
||||
console.log('[uploadPredefinedActivityImage] - drawingData:', drawingData);
|
||||
|
||||
const imageRecord = await PredefinedActivityImage.create({
|
||||
predefinedActivityId: id,
|
||||
imagePath: filePath,
|
||||
mimeType: 'image/jpeg',
|
||||
drawingData: drawingData ? JSON.stringify(drawingData) : null,
|
||||
});
|
||||
|
||||
// Optional: als imageLink am Activity-Datensatz setzen
|
||||
activity.imageLink = `/api/predefined-activities/${id}/image/${imageRecord.id}`;
|
||||
await activity.save();
|
||||
|
||||
res.status(201).json({ id: imageRecord.id, imageLink: activity.imageLink });
|
||||
} catch (error) {
|
||||
console.error('[uploadPredefinedActivityImage] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deletePredefinedActivityImage = async (req, res) => {
|
||||
try {
|
||||
const { id, imageId } = req.params; // predefinedActivityId, imageId
|
||||
const { authcode: userToken } = req.headers;
|
||||
await checkGlobalAccess(userToken);
|
||||
|
||||
const activity = await PredefinedActivity.findByPk(id);
|
||||
if (!activity) {
|
||||
return res.status(404).json({ error: 'Predefined activity not found' });
|
||||
}
|
||||
|
||||
const image = await PredefinedActivityImage.findOne({
|
||||
where: { id: imageId, predefinedActivityId: id }
|
||||
});
|
||||
if (!image) {
|
||||
return res.status(404).json({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
// Datei vom Dateisystem löschen
|
||||
if (fs.existsSync(image.imagePath)) {
|
||||
fs.unlinkSync(image.imagePath);
|
||||
}
|
||||
|
||||
// Datensatz aus der Datenbank löschen
|
||||
await image.destroy();
|
||||
|
||||
// Falls das gelöschte Bild der aktuelle imageLink war, diesen zurücksetzen
|
||||
if (activity.imageLink === `/api/predefined-activities/${id}/image/${imageId}`) {
|
||||
activity.imageLink = null;
|
||||
await activity.save();
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Image deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('[deletePredefinedActivityImage] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete image' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@ class TrainingStatsController {
|
||||
const twelveMonthsAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||
|
||||
// Alle aktiven Mitglieder des Vereins laden
|
||||
// Alle aktiven Mitglieder des spezifischen Vereins laden
|
||||
const members = await Member.findAll({
|
||||
where: {
|
||||
active: true
|
||||
active: true,
|
||||
clubId: parseInt(clubId)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,6 +95,10 @@ class TrainingStatsController {
|
||||
endTime: '--:--'
|
||||
}));
|
||||
|
||||
// Letztes Training
|
||||
const lastTrainingDate = trainingDetails.length ? trainingDetails[0].diaryDate.date : null;
|
||||
const lastTrainingTs = lastTrainingDate ? new Date(lastTrainingDate).getTime() : 0;
|
||||
|
||||
stats.push({
|
||||
id: member.id,
|
||||
firstName: member.firstName,
|
||||
@@ -102,6 +107,8 @@ class TrainingStatsController {
|
||||
participation12Months,
|
||||
participation3Months,
|
||||
participationTotal,
|
||||
lastTraining: lastTrainingDate,
|
||||
lastTrainingTs,
|
||||
trainingDetails: formattedTrainingDetails
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Sequelize } from 'sequelize';
|
||||
import { development } from './config.js';
|
||||
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
development.database,
|
||||
development.database,
|
||||
development.username,
|
||||
development.password,
|
||||
{
|
||||
host: development.host,
|
||||
dialect: development.dialect,
|
||||
define: development.define,
|
||||
logging: false, // SQL-Logging deaktivieren
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: Add drawing_data column to predefined_activity_images table
|
||||
-- Date: 2025-09-22
|
||||
-- Description: Adds drawing_data column to store Court Drawing Tool metadata
|
||||
|
||||
ALTER TABLE `predefined_activity_images`
|
||||
ADD COLUMN `drawing_data` TEXT NULL
|
||||
COMMENT 'JSON string containing drawing metadata for Court Drawing Tool'
|
||||
AFTER `mime_type`;
|
||||
|
||||
-- Verify the column was added
|
||||
DESCRIBE `predefined_activity_images`;
|
||||
26
backend/models/DiaryMemberActivity.js
Normal file
26
backend/models/DiaryMemberActivity.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const DiaryMemberActivity = sequelize.define('DiaryMemberActivity', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
diaryDateActivityId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
participantId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
}, {
|
||||
tableName: 'diary_member_activities',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
});
|
||||
|
||||
export default DiaryMemberActivity;
|
||||
|
||||
|
||||
@@ -122,6 +122,12 @@ const Member = sequelize.define('Member', {
|
||||
allowNull: false,
|
||||
default: false,
|
||||
}
|
||||
,
|
||||
gender: {
|
||||
type: DataTypes.ENUM('male','female','diverse','unknown'),
|
||||
allowNull: true,
|
||||
defaultValue: 'unknown'
|
||||
}
|
||||
}, {
|
||||
underscored: true,
|
||||
sequelize,
|
||||
|
||||
28
backend/models/OfficialCompetition.js
Normal file
28
backend/models/OfficialCompetition.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const OfficialCompetition = sequelize.define('OfficialCompetition', {
|
||||
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||
tournamentId: { type: DataTypes.INTEGER, allowNull: false },
|
||||
// Englische Attributnamen, gemappt auf bestehende DB-Spalten
|
||||
ageClassCompetition: { type: DataTypes.STRING, allowNull: true, field: 'age_class_competition' },
|
||||
performanceClass: { type: DataTypes.STRING, allowNull: true, field: 'performance_class' },
|
||||
startTime: { type: DataTypes.STRING, allowNull: true, field: 'start_time' },
|
||||
registrationDeadlineDate: { type: DataTypes.STRING, allowNull: true, field: 'registration_deadline_date' },
|
||||
registrationDeadlineOnline: { type: DataTypes.STRING, allowNull: true, field: 'registration_deadline_online' },
|
||||
cutoffDate: { type: DataTypes.STRING, allowNull: true, field: 'cutoff_date' },
|
||||
ttrRelevant: { type: DataTypes.STRING, allowNull: true },
|
||||
openTo: { type: DataTypes.STRING, allowNull: true, field: 'open_to' },
|
||||
preliminaryRound: { type: DataTypes.STRING, allowNull: true, field: 'preliminary_round' },
|
||||
finalRound: { type: DataTypes.STRING, allowNull: true, field: 'final_round' },
|
||||
maxParticipants: { type: DataTypes.STRING, allowNull: true, field: 'max_participants' },
|
||||
entryFee: { type: DataTypes.STRING, allowNull: true, field: 'entry_fee' },
|
||||
}, {
|
||||
tableName: 'official_competitions',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
});
|
||||
|
||||
export default OfficialCompetition;
|
||||
|
||||
|
||||
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;
|
||||
|
||||
|
||||
23
backend/models/OfficialTournament.js
Normal file
23
backend/models/OfficialTournament.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const OfficialTournament = sequelize.define('OfficialTournament', {
|
||||
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||
clubId: { type: DataTypes.INTEGER, allowNull: false },
|
||||
title: { type: DataTypes.STRING, allowNull: true },
|
||||
eventDate: { type: DataTypes.STRING, allowNull: true },
|
||||
organizer: { type: DataTypes.STRING, allowNull: true },
|
||||
host: { type: DataTypes.STRING, allowNull: true },
|
||||
venues: { type: DataTypes.TEXT, allowNull: true }, // JSON.stringify(Array)
|
||||
competitionTypes: { type: DataTypes.TEXT, allowNull: true }, // JSON.stringify(Array)
|
||||
registrationDeadlines: { type: DataTypes.TEXT, allowNull: true }, // JSON.stringify(Array)
|
||||
entryFees: { type: DataTypes.TEXT, allowNull: true }, // JSON.stringify(Object) - Teilnahmegebühren pro Spielklasse
|
||||
}, {
|
||||
tableName: 'official_tournaments',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
});
|
||||
|
||||
export default OfficialTournament;
|
||||
|
||||
|
||||
@@ -11,10 +11,19 @@ const PredefinedActivity = sequelize.define('PredefinedActivity', {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
code: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
drawingData: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'JSON string with metadata for Court Drawing Tool'
|
||||
},
|
||||
durationText: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
@@ -23,6 +32,10 @@ const PredefinedActivity = sequelize.define('PredefinedActivity', {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
imageLink: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
}, {
|
||||
tableName: 'predefined_activities',
|
||||
timestamps: true,
|
||||
|
||||
35
backend/models/PredefinedActivityImage.js
Normal file
35
backend/models/PredefinedActivityImage.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const PredefinedActivityImage = sequelize.define('PredefinedActivityImage', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
predefinedActivityId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
imagePath: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
mimeType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
drawingData: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'JSON string containing drawing metadata for Court Drawing Tool'
|
||||
},
|
||||
}, {
|
||||
tableName: 'predefined_activity_images',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
});
|
||||
|
||||
export default PredefinedActivityImage;
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import DiaryDateTag from './DiaryDateTag.js';
|
||||
import DiaryMemberNote from './DiaryMemberNote.js';
|
||||
import DiaryMemberTag from './DiaryMemberTag.js';
|
||||
import PredefinedActivity from './PredefinedActivity.js';
|
||||
import DiaryMemberActivity from './DiaryMemberActivity.js';
|
||||
import PredefinedActivityImage from './PredefinedActivityImage.js';
|
||||
import DiaryDateActivity from './DiaryDateActivity.js';
|
||||
import Match from './Match.js';
|
||||
import League from './League.js';
|
||||
@@ -28,6 +30,19 @@ import TournamentMatch from './TournamentMatch.js';
|
||||
import TournamentResult from './TournamentResult.js';
|
||||
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' });
|
||||
@@ -76,6 +91,14 @@ DiaryDateActivity.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDa
|
||||
|
||||
PredefinedActivity.hasMany(DiaryDateActivity, { foreignKey: 'predefinedActivityId', as: 'predefinedActivities' });
|
||||
DiaryDateActivity.belongsTo(PredefinedActivity, { foreignKey: 'predefinedActivityId', as: 'predefinedActivity' });
|
||||
// DiaryMemberActivity links a Participant to a DiaryDateActivity
|
||||
DiaryMemberActivity.belongsTo(DiaryDateActivity, { foreignKey: 'diaryDateActivityId', as: 'activity' });
|
||||
DiaryDateActivity.hasMany(DiaryMemberActivity, { foreignKey: 'diaryDateActivityId', as: 'activityMembers' });
|
||||
DiaryMemberActivity.belongsTo(Participant, { foreignKey: 'participantId', as: 'participant' });
|
||||
Participant.hasMany(DiaryMemberActivity, { foreignKey: 'participantId', as: 'memberActivities' });
|
||||
// PredefinedActivity Images
|
||||
PredefinedActivity.hasMany(PredefinedActivityImage, { foreignKey: 'predefinedActivityId', as: 'images' });
|
||||
PredefinedActivityImage.belongsTo(PredefinedActivity, { foreignKey: 'predefinedActivityId', as: 'predefinedActivity' });
|
||||
|
||||
Club.hasMany(Match, { foreignKey: 'clubId', as: 'matches' });
|
||||
Match.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
@@ -198,6 +221,8 @@ export {
|
||||
DiaryMemberNote,
|
||||
DiaryMemberTag,
|
||||
PredefinedActivity,
|
||||
DiaryMemberActivity,
|
||||
PredefinedActivityImage,
|
||||
DiaryDateActivity,
|
||||
Match,
|
||||
League,
|
||||
@@ -211,4 +236,7 @@ export {
|
||||
TournamentResult,
|
||||
Accident,
|
||||
UserToken,
|
||||
OfficialTournament,
|
||||
OfficialCompetition,
|
||||
OfficialCompetitionMember,
|
||||
};
|
||||
|
||||
410
backend/node_modules/.package-lock.json
generated
vendored
410
backend/node_modules/.package-lock.json
generated
vendored
@@ -4,6 +4,17 @@
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz",
|
||||
"integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==",
|
||||
"ideallyInert": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
|
||||
@@ -180,6 +191,137 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
||||
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
||||
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
||||
@@ -196,6 +338,23 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||
@@ -212,6 +371,75 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
||||
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
||||
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
||||
@@ -234,6 +462,29 @@
|
||||
"@img/sharp-libvips-linux-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||
@@ -256,6 +507,66 @@
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
||||
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
||||
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"ideallyInert": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||
@@ -573,9 +884,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -848,9 +1160,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -879,10 +1192,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -1265,16 +1579,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
|
||||
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.6.0",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
@@ -1288,7 +1603,7 @@
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.10",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
@@ -1303,6 +1618,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/encodeurl": {
|
||||
@@ -1477,6 +1796,21 @@
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"ideallyInert": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -2302,6 +2636,12 @@
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
|
||||
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
|
||||
},
|
||||
"node_modules/node-ensure": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
|
||||
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@@ -2554,9 +2894,37 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
|
||||
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz",
|
||||
"integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^3.1.0",
|
||||
"node-ensure": "^0.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-parse/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-parse/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
@@ -3240,6 +3608,14 @@
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
|
||||
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
|
||||
"ideallyInert": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
2
backend/node_modules/brace-expansion/index.js
generated
vendored
2
backend/node_modules/brace-expansion/index.js
generated
vendored
@@ -109,7 +109,7 @@ function expand(str, isTop) {
|
||||
var isOptions = m.body.indexOf(',') >= 0;
|
||||
if (!isSequence && !isOptions) {
|
||||
// {a},b}
|
||||
if (m.post.match(/,.*\}/)) {
|
||||
if (m.post.match(/,(?!,).*\}/)) {
|
||||
str = m.pre + '{' + m.body + escClose + m.post;
|
||||
return expand(str);
|
||||
}
|
||||
|
||||
5
backend/node_modules/brace-expansion/package.json
generated
vendored
5
backend/node_modules/brace-expansion/package.json
generated
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "brace-expansion",
|
||||
"description": "Brace expansion as known from sh/bash",
|
||||
"version": "1.1.11",
|
||||
"version": "1.1.12",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/juliangruber/brace-expansion.git"
|
||||
@@ -43,5 +43,8 @@
|
||||
"iphone/6.0..latest",
|
||||
"android-browser/4.2..latest"
|
||||
]
|
||||
},
|
||||
"publishConfig": {
|
||||
"tag": "1.x"
|
||||
}
|
||||
}
|
||||
|
||||
147
backend/node_modules/cookie/HISTORY.md
generated
vendored
147
backend/node_modules/cookie/HISTORY.md
generated
vendored
@@ -1,147 +0,0 @@
|
||||
0.6.0 / 2023-11-06
|
||||
==================
|
||||
|
||||
* Add `partitioned` option
|
||||
|
||||
0.5.0 / 2022-04-11
|
||||
==================
|
||||
|
||||
* Add `priority` option
|
||||
* Fix `expires` option to reject invalid dates
|
||||
* perf: improve default decode speed
|
||||
* perf: remove slow string split in parse
|
||||
|
||||
0.4.2 / 2022-02-02
|
||||
==================
|
||||
|
||||
* perf: read value only when assigning in parse
|
||||
* perf: remove unnecessary regexp in parse
|
||||
|
||||
0.4.1 / 2020-04-21
|
||||
==================
|
||||
|
||||
* Fix `maxAge` option to reject invalid values
|
||||
|
||||
0.4.0 / 2019-05-15
|
||||
==================
|
||||
|
||||
* Add `SameSite=None` support
|
||||
|
||||
0.3.1 / 2016-05-26
|
||||
==================
|
||||
|
||||
* Fix `sameSite: true` to work with draft-7 clients
|
||||
- `true` now sends `SameSite=Strict` instead of `SameSite`
|
||||
|
||||
0.3.0 / 2016-05-26
|
||||
==================
|
||||
|
||||
* Add `sameSite` option
|
||||
- Replaces `firstPartyOnly` option, never implemented by browsers
|
||||
* Improve error message when `encode` is not a function
|
||||
* Improve error message when `expires` is not a `Date`
|
||||
|
||||
0.2.4 / 2016-05-20
|
||||
==================
|
||||
|
||||
* perf: enable strict mode
|
||||
* perf: use for loop in parse
|
||||
* perf: use string concatenation for serialization
|
||||
|
||||
0.2.3 / 2015-10-25
|
||||
==================
|
||||
|
||||
* Fix cookie `Max-Age` to never be a floating point number
|
||||
|
||||
0.2.2 / 2015-09-17
|
||||
==================
|
||||
|
||||
* Fix regression when setting empty cookie value
|
||||
- Ease the new restriction, which is just basic header-level validation
|
||||
* Fix typo in invalid value errors
|
||||
|
||||
0.2.1 / 2015-09-17
|
||||
==================
|
||||
|
||||
* Throw on invalid values provided to `serialize`
|
||||
- Ensures the resulting string is a valid HTTP header value
|
||||
|
||||
0.2.0 / 2015-08-13
|
||||
==================
|
||||
|
||||
* Add `firstPartyOnly` option
|
||||
* Throw better error for invalid argument to parse
|
||||
* perf: hoist regular expression
|
||||
|
||||
0.1.5 / 2015-09-17
|
||||
==================
|
||||
|
||||
* Fix regression when setting empty cookie value
|
||||
- Ease the new restriction, which is just basic header-level validation
|
||||
* Fix typo in invalid value errors
|
||||
|
||||
0.1.4 / 2015-09-17
|
||||
==================
|
||||
|
||||
* Throw better error for invalid argument to parse
|
||||
* Throw on invalid values provided to `serialize`
|
||||
- Ensures the resulting string is a valid HTTP header value
|
||||
|
||||
0.1.3 / 2015-05-19
|
||||
==================
|
||||
|
||||
* Reduce the scope of try-catch deopt
|
||||
* Remove argument reassignments
|
||||
|
||||
0.1.2 / 2014-04-16
|
||||
==================
|
||||
|
||||
* Remove unnecessary files from npm package
|
||||
|
||||
0.1.1 / 2014-02-23
|
||||
==================
|
||||
|
||||
* Fix bad parse when cookie value contained a comma
|
||||
* Fix support for `maxAge` of `0`
|
||||
|
||||
0.1.0 / 2013-05-01
|
||||
==================
|
||||
|
||||
* Add `decode` option
|
||||
* Add `encode` option
|
||||
|
||||
0.0.6 / 2013-04-08
|
||||
==================
|
||||
|
||||
* Ignore cookie parts missing `=`
|
||||
|
||||
0.0.5 / 2012-10-29
|
||||
==================
|
||||
|
||||
* Return raw cookie value if value unescape errors
|
||||
|
||||
0.0.4 / 2012-06-21
|
||||
==================
|
||||
|
||||
* Use encode/decodeURIComponent for cookie encoding/decoding
|
||||
- Improve server/client interoperability
|
||||
|
||||
0.0.3 / 2012-06-06
|
||||
==================
|
||||
|
||||
* Only escape special characters per the cookie RFC
|
||||
|
||||
0.0.2 / 2012-06-01
|
||||
==================
|
||||
|
||||
* Fix `maxAge` option to not throw error
|
||||
|
||||
0.0.1 / 2012-05-28
|
||||
==================
|
||||
|
||||
* Add more tests
|
||||
|
||||
0.0.0 / 2012-05-28
|
||||
==================
|
||||
|
||||
* Initial release
|
||||
174
backend/node_modules/cookie/index.js
generated
vendored
174
backend/node_modules/cookie/index.js
generated
vendored
@@ -23,14 +23,66 @@ exports.serialize = serialize;
|
||||
var __toString = Object.prototype.toString
|
||||
|
||||
/**
|
||||
* RegExp to match field-content in RFC 7230 sec 3.2
|
||||
* RegExp to match cookie-name in RFC 6265 sec 4.1.1
|
||||
* This refers out to the obsoleted definition of token in RFC 2616 sec 2.2
|
||||
* which has been replaced by the token definition in RFC 7230 appendix B.
|
||||
*
|
||||
* field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
|
||||
* field-vchar = VCHAR / obs-text
|
||||
* obs-text = %x80-FF
|
||||
* cookie-name = token
|
||||
* token = 1*tchar
|
||||
* tchar = "!" / "#" / "$" / "%" / "&" / "'" /
|
||||
* "*" / "+" / "-" / "." / "^" / "_" /
|
||||
* "`" / "|" / "~" / DIGIT / ALPHA
|
||||
*/
|
||||
|
||||
var fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;
|
||||
var cookieNameRegExp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
|
||||
/**
|
||||
* RegExp to match cookie-value in RFC 6265 sec 4.1.1
|
||||
*
|
||||
* cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
|
||||
* cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
|
||||
* ; US-ASCII characters excluding CTLs,
|
||||
* ; whitespace DQUOTE, comma, semicolon,
|
||||
* ; and backslash
|
||||
*/
|
||||
|
||||
var cookieValueRegExp = /^("?)[\u0021\u0023-\u002B\u002D-\u003A\u003C-\u005B\u005D-\u007E]*\1$/;
|
||||
|
||||
/**
|
||||
* RegExp to match domain-value in RFC 6265 sec 4.1.1
|
||||
*
|
||||
* domain-value = <subdomain>
|
||||
* ; defined in [RFC1034], Section 3.5, as
|
||||
* ; enhanced by [RFC1123], Section 2.1
|
||||
* <subdomain> = <label> | <subdomain> "." <label>
|
||||
* <label> = <let-dig> [ [ <ldh-str> ] <let-dig> ]
|
||||
* Labels must be 63 characters or less.
|
||||
* 'let-dig' not 'letter' in the first char, per RFC1123
|
||||
* <ldh-str> = <let-dig-hyp> | <let-dig-hyp> <ldh-str>
|
||||
* <let-dig-hyp> = <let-dig> | "-"
|
||||
* <let-dig> = <letter> | <digit>
|
||||
* <letter> = any one of the 52 alphabetic characters A through Z in
|
||||
* upper case and a through z in lower case
|
||||
* <digit> = any one of the ten digits 0 through 9
|
||||
*
|
||||
* Keep support for leading dot: https://github.com/jshttp/cookie/issues/173
|
||||
*
|
||||
* > (Note that a leading %x2E ("."), if present, is ignored even though that
|
||||
* character is not permitted, but a trailing %x2E ("."), if present, will
|
||||
* cause the user agent to ignore the attribute.)
|
||||
*/
|
||||
|
||||
var domainValueRegExp = /^([.]?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
||||
|
||||
/**
|
||||
* RegExp to match path-value in RFC 6265 sec 4.1.1
|
||||
*
|
||||
* path-value = <any CHAR except CTLs or ";">
|
||||
* CHAR = %x01-7F
|
||||
* ; defined in RFC 5234 appendix B.1
|
||||
*/
|
||||
|
||||
var pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/;
|
||||
|
||||
/**
|
||||
* Parse a cookie header.
|
||||
@@ -39,107 +91,128 @@ var fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;
|
||||
* The object has the various cookies as keys(names) => values
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {object} [options]
|
||||
* @param {object} [opt]
|
||||
* @return {object}
|
||||
* @public
|
||||
*/
|
||||
|
||||
function parse(str, options) {
|
||||
function parse(str, opt) {
|
||||
if (typeof str !== 'string') {
|
||||
throw new TypeError('argument str must be a string');
|
||||
}
|
||||
|
||||
var obj = {}
|
||||
var opt = options || {};
|
||||
var dec = opt.decode || decode;
|
||||
var obj = {};
|
||||
var len = str.length;
|
||||
// RFC 6265 sec 4.1.1, RFC 2616 2.2 defines a cookie name consists of one char minimum, plus '='.
|
||||
if (len < 2) return obj;
|
||||
|
||||
var index = 0
|
||||
while (index < str.length) {
|
||||
var eqIdx = str.indexOf('=', index)
|
||||
var dec = (opt && opt.decode) || decode;
|
||||
var index = 0;
|
||||
var eqIdx = 0;
|
||||
var endIdx = 0;
|
||||
|
||||
// no more cookie pairs
|
||||
if (eqIdx === -1) {
|
||||
break
|
||||
}
|
||||
do {
|
||||
eqIdx = str.indexOf('=', index);
|
||||
if (eqIdx === -1) break; // No more cookie pairs.
|
||||
|
||||
var endIdx = str.indexOf(';', index)
|
||||
endIdx = str.indexOf(';', index);
|
||||
|
||||
if (endIdx === -1) {
|
||||
endIdx = str.length
|
||||
} else if (endIdx < eqIdx) {
|
||||
endIdx = len;
|
||||
} else if (eqIdx > endIdx) {
|
||||
// backtrack on prior semicolon
|
||||
index = str.lastIndexOf(';', eqIdx - 1) + 1
|
||||
continue
|
||||
index = str.lastIndexOf(';', eqIdx - 1) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = str.slice(index, eqIdx).trim()
|
||||
var keyStartIdx = startIndex(str, index, eqIdx);
|
||||
var keyEndIdx = endIndex(str, eqIdx, keyStartIdx);
|
||||
var key = str.slice(keyStartIdx, keyEndIdx);
|
||||
|
||||
// only assign once
|
||||
if (undefined === obj[key]) {
|
||||
var val = str.slice(eqIdx + 1, endIdx).trim()
|
||||
if (!obj.hasOwnProperty(key)) {
|
||||
var valStartIdx = startIndex(str, eqIdx + 1, endIdx);
|
||||
var valEndIdx = endIndex(str, endIdx, valStartIdx);
|
||||
|
||||
// quoted values
|
||||
if (val.charCodeAt(0) === 0x22) {
|
||||
val = val.slice(1, -1)
|
||||
if (str.charCodeAt(valStartIdx) === 0x22 /* " */ && str.charCodeAt(valEndIdx - 1) === 0x22 /* " */) {
|
||||
valStartIdx++;
|
||||
valEndIdx--;
|
||||
}
|
||||
|
||||
var val = str.slice(valStartIdx, valEndIdx);
|
||||
obj[key] = tryDecode(val, dec);
|
||||
}
|
||||
|
||||
index = endIdx + 1
|
||||
}
|
||||
} while (index < len);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function startIndex(str, index, max) {
|
||||
do {
|
||||
var code = str.charCodeAt(index);
|
||||
if (code !== 0x20 /* */ && code !== 0x09 /* \t */) return index;
|
||||
} while (++index < max);
|
||||
return max;
|
||||
}
|
||||
|
||||
function endIndex(str, index, min) {
|
||||
while (index > min) {
|
||||
var code = str.charCodeAt(--index);
|
||||
if (code !== 0x20 /* */ && code !== 0x09 /* \t */) return index + 1;
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize data into a cookie header.
|
||||
*
|
||||
* Serialize the a name value pair into a cookie string suitable for
|
||||
* http headers. An optional options object specified cookie parameters.
|
||||
* Serialize a name value pair into a cookie string suitable for
|
||||
* http headers. An optional options object specifies cookie parameters.
|
||||
*
|
||||
* serialize('foo', 'bar', { httpOnly: true })
|
||||
* => "foo=bar; httpOnly"
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {string} val
|
||||
* @param {object} [options]
|
||||
* @param {object} [opt]
|
||||
* @return {string}
|
||||
* @public
|
||||
*/
|
||||
|
||||
function serialize(name, val, options) {
|
||||
var opt = options || {};
|
||||
var enc = opt.encode || encode;
|
||||
function serialize(name, val, opt) {
|
||||
var enc = (opt && opt.encode) || encodeURIComponent;
|
||||
|
||||
if (typeof enc !== 'function') {
|
||||
throw new TypeError('option encode is invalid');
|
||||
}
|
||||
|
||||
if (!fieldContentRegExp.test(name)) {
|
||||
if (!cookieNameRegExp.test(name)) {
|
||||
throw new TypeError('argument name is invalid');
|
||||
}
|
||||
|
||||
var value = enc(val);
|
||||
|
||||
if (value && !fieldContentRegExp.test(value)) {
|
||||
if (!cookieValueRegExp.test(value)) {
|
||||
throw new TypeError('argument val is invalid');
|
||||
}
|
||||
|
||||
var str = name + '=' + value;
|
||||
if (!opt) return str;
|
||||
|
||||
if (null != opt.maxAge) {
|
||||
var maxAge = opt.maxAge - 0;
|
||||
var maxAge = Math.floor(opt.maxAge);
|
||||
|
||||
if (isNaN(maxAge) || !isFinite(maxAge)) {
|
||||
if (!isFinite(maxAge)) {
|
||||
throw new TypeError('option maxAge is invalid')
|
||||
}
|
||||
|
||||
str += '; Max-Age=' + Math.floor(maxAge);
|
||||
str += '; Max-Age=' + maxAge;
|
||||
}
|
||||
|
||||
if (opt.domain) {
|
||||
if (!fieldContentRegExp.test(opt.domain)) {
|
||||
if (!domainValueRegExp.test(opt.domain)) {
|
||||
throw new TypeError('option domain is invalid');
|
||||
}
|
||||
|
||||
@@ -147,7 +220,7 @@ function serialize(name, val, options) {
|
||||
}
|
||||
|
||||
if (opt.path) {
|
||||
if (!fieldContentRegExp.test(opt.path)) {
|
||||
if (!pathValueRegExp.test(opt.path)) {
|
||||
throw new TypeError('option path is invalid');
|
||||
}
|
||||
|
||||
@@ -178,8 +251,7 @@ function serialize(name, val, options) {
|
||||
|
||||
if (opt.priority) {
|
||||
var priority = typeof opt.priority === 'string'
|
||||
? opt.priority.toLowerCase()
|
||||
: opt.priority
|
||||
? opt.priority.toLowerCase() : opt.priority;
|
||||
|
||||
switch (priority) {
|
||||
case 'low':
|
||||
@@ -234,17 +306,6 @@ function decode (str) {
|
||||
: str
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-encode value.
|
||||
*
|
||||
* @param {string} val
|
||||
* @returns {string}
|
||||
*/
|
||||
|
||||
function encode (val) {
|
||||
return encodeURIComponent(val)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if value is a Date.
|
||||
*
|
||||
@@ -253,8 +314,7 @@ function encode (val) {
|
||||
*/
|
||||
|
||||
function isDate (val) {
|
||||
return __toString.call(val) === '[object Date]' ||
|
||||
val instanceof Date
|
||||
return __toString.call(val) === '[object Date]';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
6
backend/node_modules/cookie/package.json
generated
vendored
6
backend/node_modules/cookie/package.json
generated
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cookie",
|
||||
"description": "HTTP server cookie parsing and serialization",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.1",
|
||||
"author": "Roman Shtylman <shtylman@gmail.com>",
|
||||
"contributors": [
|
||||
"Douglas Christopher Wilson <doug@somethingdoug.com>"
|
||||
@@ -29,6 +29,7 @@
|
||||
"SECURITY.md",
|
||||
"index.js"
|
||||
],
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
},
|
||||
@@ -38,7 +39,6 @@
|
||||
"test": "mocha --reporter spec --bail --check-leaks test/",
|
||||
"test-ci": "nyc --reporter=lcov --reporter=text npm test",
|
||||
"test-cov": "nyc --reporter=html --reporter=text npm test",
|
||||
"update-bench": "node scripts/update-benchmark.js",
|
||||
"version": "node scripts/version-history.js && git add HISTORY.md"
|
||||
"update-bench": "node scripts/update-benchmark.js"
|
||||
}
|
||||
}
|
||||
|
||||
14
backend/node_modules/express/History.md
generated
vendored
14
backend/node_modules/express/History.md
generated
vendored
@@ -1,3 +1,17 @@
|
||||
4.21.2 / 2024-11-06
|
||||
==========
|
||||
|
||||
* deps: path-to-regexp@0.1.12
|
||||
- Fix backtracking protection
|
||||
* deps: path-to-regexp@0.1.11
|
||||
- Throws an error on invalid path values
|
||||
|
||||
4.21.1 / 2024-10-08
|
||||
==========
|
||||
|
||||
* Backported a fix for [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)
|
||||
|
||||
|
||||
4.21.0 / 2024-09-11
|
||||
==========
|
||||
|
||||
|
||||
10
backend/node_modules/express/package.json
generated
vendored
10
backend/node_modules/express/package.json
generated
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "express",
|
||||
"description": "Fast, unopinionated, minimalist web framework",
|
||||
"version": "4.21.0",
|
||||
"version": "4.21.2",
|
||||
"author": "TJ Holowaychuk <tj@vision-media.ca>",
|
||||
"contributors": [
|
||||
"Aaron Heckmann <aaron.heckmann+github@gmail.com>",
|
||||
@@ -15,6 +15,10 @@
|
||||
"license": "MIT",
|
||||
"repository": "expressjs/express",
|
||||
"homepage": "http://expressjs.com/",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
},
|
||||
"keywords": [
|
||||
"express",
|
||||
"framework",
|
||||
@@ -33,7 +37,7 @@
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.6.0",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
@@ -47,7 +51,7 @@
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.10",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
|
||||
16
backend/node_modules/path-to-regexp/index.js
generated
vendored
16
backend/node_modules/path-to-regexp/index.js
generated
vendored
@@ -65,23 +65,33 @@ function pathToRegexp(path, keys, options) {
|
||||
return new RegExp(path.join('|'), flags);
|
||||
}
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
throw new TypeError('path must be a string, array of strings, or regular expression');
|
||||
}
|
||||
|
||||
path = path.replace(
|
||||
/\\.|(\/)?(\.)?:(\w+)(\(.*?\))?(\*)?(\?)?|[.*]|\/\(/g,
|
||||
function (match, slash, format, key, capture, star, optional, offset) {
|
||||
pos = offset + match.length;
|
||||
|
||||
if (match[0] === '\\') {
|
||||
backtrack += match;
|
||||
pos += 2;
|
||||
return match;
|
||||
}
|
||||
|
||||
if (match === '.') {
|
||||
backtrack += '\\.';
|
||||
extraOffset += 1;
|
||||
pos += 1;
|
||||
return '\\.';
|
||||
}
|
||||
|
||||
backtrack = slash || format ? '' : path.slice(pos, offset);
|
||||
if (slash || format) {
|
||||
backtrack = '';
|
||||
} else {
|
||||
backtrack += path.slice(pos, offset);
|
||||
}
|
||||
|
||||
pos = offset + match.length;
|
||||
|
||||
if (match === '*') {
|
||||
extraOffset += 3;
|
||||
|
||||
2
backend/node_modules/path-to-regexp/package.json
generated
vendored
2
backend/node_modules/path-to-regexp/package.json
generated
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "path-to-regexp",
|
||||
"description": "Express style path to RegExp utility",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.12",
|
||||
"files": [
|
||||
"index.js",
|
||||
"LICENSE"
|
||||
|
||||
77
backend/package-lock.json
generated
77
backend/package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.10.3",
|
||||
"nodemailer": "^6.9.14",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"sequelize": "^6.37.3",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
@@ -893,9 +894,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -1168,9 +1170,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -1199,10 +1202,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -1585,16 +1589,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
|
||||
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.6.0",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
@@ -1608,7 +1613,7 @@
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.10",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
@@ -1623,6 +1628,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/encodeurl": {
|
||||
@@ -2636,6 +2645,12 @@
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
|
||||
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
|
||||
},
|
||||
"node_modules/node-ensure": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
|
||||
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@@ -2888,9 +2903,37 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
|
||||
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz",
|
||||
"integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^3.1.0",
|
||||
"node-ensure": "^0.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-parse/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-parse/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "cd ../frontend && npm install && npm run build",
|
||||
"dev": "nodemon server.js"
|
||||
"dev": "nodemon server.js",
|
||||
"cleanup:usertoken": "node ./scripts/cleanupUserTokenKeys.js",
|
||||
"cleanup:indexes": "node ./scripts/cleanupAllIndexes.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -16,6 +18,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"crypto": "^1.0.1",
|
||||
"csv-parser": "^3.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
@@ -23,6 +26,7 @@
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.10.3",
|
||||
"nodemailer": "^6.9.14",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"sequelize": "^6.37.3",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
|
||||
15
backend/routes/diaryMemberActivityRoutes.js
Normal file
15
backend/routes/diaryMemberActivityRoutes.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { addMembersToActivity, removeMemberFromActivity, getMembersForActivity } from '../controllers/diaryMemberActivityController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId/:diaryDateActivityId', getMembersForActivity);
|
||||
router.post('/:clubId/:diaryDateActivityId', addMembersToActivity);
|
||||
router.delete('/:clubId/:diaryDateActivityId/:participantId', removeMemberFromActivity);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
deleteDiaryNote,
|
||||
addDiaryTag,
|
||||
addTagToDiaryDate,
|
||||
deleteTagFromDiaryDate
|
||||
deleteTagFromDiaryDate,
|
||||
deleteDateForClub,
|
||||
} from '../controllers/diaryController.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -21,5 +22,6 @@ router.delete('/:clubId/tag', authenticate, deleteTagFromDiaryDate);
|
||||
router.get('/:clubId', authenticate, getDatesForClub);
|
||||
router.post('/:clubId', authenticate, createDateForClub);
|
||||
router.put('/:clubId', authenticate, updateTrainingTimes);
|
||||
router.delete('/:clubId/:dateId', authenticate, deleteDateForClub);
|
||||
|
||||
export default router;
|
||||
|
||||
21
backend/routes/officialTournamentRoutes.js
Normal file
21
backend/routes/officialTournamentRoutes.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, upsertCompetitionMember, listClubParticipations, updateParticipantStatus } from '../controllers/officialTournamentController.js';
|
||||
|
||||
const router = express.Router();
|
||||
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);
|
||||
router.post('/:clubId/:id/participation', upsertCompetitionMember);
|
||||
router.post('/:clubId/:id/status', updateParticipantStatus);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -4,13 +4,41 @@ import {
|
||||
getAllPredefinedActivities,
|
||||
getPredefinedActivityById,
|
||||
updatePredefinedActivity,
|
||||
searchPredefinedActivities,
|
||||
mergePredefinedActivities,
|
||||
deduplicatePredefinedActivities,
|
||||
} from '../controllers/predefinedActivityController.js';
|
||||
import multer from 'multer';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { uploadPredefinedActivityImage, deletePredefinedActivityImage } from '../controllers/predefinedActivityImageController.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
router.post('/', createPredefinedActivity);
|
||||
router.get('/', getAllPredefinedActivities);
|
||||
router.get('/:id', getPredefinedActivityById);
|
||||
router.put('/:id', updatePredefinedActivity);
|
||||
router.post('/', authenticate, createPredefinedActivity);
|
||||
router.get('/', authenticate, getAllPredefinedActivities);
|
||||
router.get('/:id', authenticate, getPredefinedActivityById);
|
||||
router.put('/:id', authenticate, updatePredefinedActivity);
|
||||
router.post('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage);
|
||||
router.put('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage);
|
||||
router.delete('/:id/image/:imageId', authenticate, deletePredefinedActivityImage);
|
||||
router.get('/search/query', authenticate, searchPredefinedActivities);
|
||||
router.post('/merge', authenticate, mergePredefinedActivities);
|
||||
router.post('/deduplicate', authenticate, deduplicatePredefinedActivities);
|
||||
router.get('/:id/image/:imageId', async (req, res) => {
|
||||
try {
|
||||
const { id, imageId } = req.params;
|
||||
const image = await PredefinedActivityImage.findOne({ where: { id: imageId, predefinedActivityId: id } });
|
||||
if (!image) return res.status(404).json({ error: 'Image not found' });
|
||||
if (!fs.existsSync(image.imagePath)) return res.status(404).json({ error: 'Image file missing' });
|
||||
res.sendFile(path.resolve(image.imagePath));
|
||||
} catch (e) {
|
||||
console.error('[getPredefinedActivityImage] - Error:', e);
|
||||
res.status(500).json({ error: 'Failed to fetch image' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
100
backend/scripts/cleanupAllIndexes.js
Normal file
100
backend/scripts/cleanupAllIndexes.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'trainingdiary',
|
||||
};
|
||||
|
||||
async function getTables(connection) {
|
||||
const [rows] = await connection.execute(
|
||||
`SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE='BASE TABLE'`,
|
||||
[dbConfig.database]
|
||||
);
|
||||
return rows.map(r => r.TABLE_NAME);
|
||||
}
|
||||
|
||||
async function getIndexSummary(connection, table) {
|
||||
const [rows] = await connection.execute(`SHOW INDEX FROM \`${table}\``);
|
||||
const byName = rows.reduce((acc, r) => {
|
||||
const key = r.Key_name;
|
||||
if (!acc[key]) acc[key] = { nonUnique: r.Non_unique === 1, seqMap: {}, columns: [] };
|
||||
acc[key].seqMap[r.Seq_in_index] = r.Column_name;
|
||||
return acc;
|
||||
}, {});
|
||||
// normalize columns order by seq
|
||||
for (const name of Object.keys(byName)) {
|
||||
const cols = Object.keys(byName[name].seqMap)
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map(k => byName[name].seqMap[k]);
|
||||
byName[name].columns = cols;
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
async function cleanupDuplicates(connection, table) {
|
||||
const before = await getIndexSummary(connection, table);
|
||||
const keepSignatureToName = new Map();
|
||||
const dropNames = [];
|
||||
|
||||
for (const [name, info] of Object.entries(before)) {
|
||||
if (name === 'PRIMARY') continue; // niemals Primary droppen
|
||||
const uniqueFlag = info.nonUnique ? 'N' : 'U';
|
||||
const sig = `${uniqueFlag}|${info.columns.join(',')}`;
|
||||
if (!keepSignatureToName.has(sig)) {
|
||||
keepSignatureToName.set(sig, name);
|
||||
} else {
|
||||
// doppelter Index mit gleicher Spaltenliste und gleicher Einzigartigkeit
|
||||
dropNames.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const idxName of dropNames) {
|
||||
try {
|
||||
await connection.execute(`DROP INDEX \`${idxName}\` ON \`${table}\``);
|
||||
console.log(`[drop] ${table}: ${idxName}`);
|
||||
} catch (e) {
|
||||
console.warn(`[warn] ${table}: konnte Index ${idxName} nicht löschen: ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const after = await getIndexSummary(connection, table);
|
||||
return { beforeCount: Object.keys(before).length, afterCount: Object.keys(after).length, dropped: dropNames.length };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let connection;
|
||||
try {
|
||||
console.log('Connecting to DB:', dbConfig);
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
|
||||
const tables = await getTables(connection);
|
||||
console.log(`Found ${tables.length} tables`);
|
||||
|
||||
let totalBefore = 0;
|
||||
let totalAfter = 0;
|
||||
let totalDropped = 0;
|
||||
|
||||
for (const table of tables) {
|
||||
const { beforeCount, afterCount, dropped } = await cleanupDuplicates(connection, table);
|
||||
totalBefore += beforeCount;
|
||||
totalAfter += afterCount;
|
||||
totalDropped += dropped;
|
||||
}
|
||||
|
||||
console.log('Summary:', { totalBefore, totalAfter, totalDropped });
|
||||
} catch (e) {
|
||||
console.error('Cleanup failed:', e);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (connection) await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
|
||||
90
backend/scripts/cleanupUserTokenKeys.js
Normal file
90
backend/scripts/cleanupUserTokenKeys.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'trainingdiary',
|
||||
};
|
||||
|
||||
async function getIndexSummary(connection, table) {
|
||||
const [rows] = await connection.execute(`SHOW INDEX FROM \`${table}\``);
|
||||
const summary = rows.reduce((acc, r) => {
|
||||
const key = r.Key_name;
|
||||
acc[key] = acc[key] || { unique: r.Non_unique === 0, columns: [] };
|
||||
acc[key].columns.push(r.Column_name);
|
||||
return acc;
|
||||
}, {});
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function cleanupUserTokenKeys() {
|
||||
let connection;
|
||||
const table = 'UserToken';
|
||||
|
||||
try {
|
||||
console.log('Connecting to DB:', dbConfig);
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
|
||||
console.log(`\nBefore cleanup (indexes on ${table}):`);
|
||||
let before = await getIndexSummary(connection, table);
|
||||
Object.entries(before).forEach(([name, info]) => {
|
||||
console.log(` - ${name} ${info.unique ? '(UNIQUE)' : ''} -> [${info.columns.join(', ')}]`);
|
||||
});
|
||||
|
||||
// Drop all non-PRIMARY indexes on UserToken
|
||||
const [indexes] = await connection.execute(`SHOW INDEX FROM \`${table}\``);
|
||||
const keyNames = Array.from(new Set(indexes.map(i => i.Key_name))).filter(k => k !== 'PRIMARY');
|
||||
|
||||
for (const keyName of keyNames) {
|
||||
try {
|
||||
await connection.execute(`DROP INDEX \`${keyName}\` ON \`${table}\``);
|
||||
console.log(`Dropped index: ${keyName}`);
|
||||
} catch (err) {
|
||||
console.warn(`Could not drop ${keyName}: ${err.code || err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-create minimal, deterministic indexes
|
||||
// Unique on token (column is 'token')
|
||||
try {
|
||||
await connection.execute(`CREATE UNIQUE INDEX \`uniq_UserToken_token\` ON \`${table}\` (\`token\`)`);
|
||||
console.log('Created UNIQUE index: uniq_UserToken_token (token)');
|
||||
} catch (err) {
|
||||
console.warn('Could not create uniq_UserToken_token:', err.code || err.message);
|
||||
}
|
||||
|
||||
// Helpful index on user_id if column exists
|
||||
try {
|
||||
const [cols] = await connection.execute(`SHOW COLUMNS FROM \`${table}\` LIKE 'user_id'`);
|
||||
if (cols && cols.length > 0) {
|
||||
await connection.execute(`CREATE INDEX \`idx_UserToken_user_id\` ON \`${table}\` (\`user_id\`)`);
|
||||
console.log('Created INDEX: idx_UserToken_user_id (user_id)');
|
||||
} else {
|
||||
console.log('Column user_id not found, skip creating idx_UserToken_user_id');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not create idx_UserToken_user_id:', err.code || err.message);
|
||||
}
|
||||
|
||||
console.log(`\nAfter cleanup (indexes on ${table}):`);
|
||||
const after = await getIndexSummary(connection, table);
|
||||
Object.entries(after).forEach(([name, info]) => {
|
||||
console.log(` - ${name} ${info.unique ? '(UNIQUE)' : ''} -> [${info.columns.join(', ')}]`);
|
||||
});
|
||||
|
||||
console.log('\nDone.');
|
||||
} catch (err) {
|
||||
console.error('Cleanup failed:', err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (connection) await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
cleanupUserTokenKeys();
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import cors from 'cors';
|
||||
import {
|
||||
User, Log, Club, UserClub, Member, DiaryDate, Participant, Activity, MemberNote,
|
||||
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
|
||||
PredefinedActivity, DiaryDateActivity, Match, League, Team, Group,
|
||||
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, Group,
|
||||
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
|
||||
TournamentMember, Accident, UserToken
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember
|
||||
} from './models/index.js';
|
||||
import authRoutes from './routes/authRoutes.js';
|
||||
import clubRoutes from './routes/clubRoutes.js';
|
||||
@@ -22,6 +22,7 @@ import diaryNoteRoutes from './routes/diaryNoteRoutes.js';
|
||||
import diaryMemberRoutes from './routes/diaryMemberRoutes.js';
|
||||
import predefinedActivityRoutes from './routes/predefinedActivityRoutes.js';
|
||||
import diaryDateActivityRoutes from './routes/diaryDateActivityRoutes.js';
|
||||
import diaryMemberActivityRoutes from './routes/diaryMemberActivityRoutes.js';
|
||||
import matchRoutes from './routes/matchRoutes.js';
|
||||
import Season from './models/Season.js';
|
||||
import Location from './models/Location.js';
|
||||
@@ -31,6 +32,7 @@ import sessionRoutes from './routes/sessionRoutes.js';
|
||||
import tournamentRoutes from './routes/tournamentRoutes.js';
|
||||
import accidentRoutes from './routes/accidentRoutes.js';
|
||||
import trainingStatsRoutes from './routes/trainingStatsRoutes.js';
|
||||
import officialTournamentRoutes from './routes/officialTournamentRoutes.js';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
@@ -41,6 +43,14 @@ const __dirname = path.dirname(__filename);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Globale Fehlerbehandlung, damit der Server bei unerwarteten Fehlern nicht hart abstürzt
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[uncaughtException]', err);
|
||||
});
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('[unhandledRejection]', reason);
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/clubs', clubRoutes);
|
||||
app.use('/api/clubmembers', memberRoutes);
|
||||
@@ -53,6 +63,7 @@ app.use('/api/tags', diaryTagRoutes);
|
||||
app.use('/api/diarymember', diaryMemberRoutes);
|
||||
app.use('/api/predefined-activities', predefinedActivityRoutes);
|
||||
app.use('/api/diary-date-activities', diaryDateActivityRoutes);
|
||||
app.use('/api/diary-member-activities', diaryMemberActivityRoutes);
|
||||
app.use('/api/matches', matchRoutes);
|
||||
app.use('/api/group', groupRoutes);
|
||||
app.use('/api/diarydatetags', diaryDateTagRoutes);
|
||||
@@ -60,6 +71,7 @@ app.use('/api/session', sessionRoutes);
|
||||
app.use('/api/tournament', tournamentRoutes);
|
||||
app.use('/api/accident', accidentRoutes);
|
||||
app.use('/api/training-stats', trainingStatsRoutes);
|
||||
app.use('/api/official-tournaments', officialTournamentRoutes);
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
||||
|
||||
@@ -72,37 +84,93 @@ app.get('*', (req, res) => {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
|
||||
await User.sync({ alter: true });
|
||||
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 DiaryDateActivity.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({ alter: true });
|
||||
// Einmalige Migration: deutsche Spaltennamen -> englische
|
||||
const renameColumnIfExists = async (table, from, to, typeSql) => {
|
||||
try {
|
||||
const [rows] = await sequelize.query(`SHOW COLUMNS FROM \`${table}\` LIKE :col`, { replacements: { col: from } });
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
await sequelize.query(`ALTER TABLE \`${table}\` CHANGE \`${from}\` \`${to}\` ${typeSql}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[migration] Failed to rename ${table}.${from} -> ${to}:`, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// official_competitions
|
||||
await renameColumnIfExists('official_competitions', 'altersklasse_wettbewerb', 'age_class_competition', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_competitions', 'leistungsklasse', 'performance_class', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_competitions', 'startzeit', 'start_time', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_competitions', 'meldeschluss_datum', 'registration_deadline_date', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_competitions', 'meldeschluss_online', 'registration_deadline_online', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_competitions', 'stichtag', 'cutoff_date', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_competitions', 'offen_fuer', 'open_to', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_competitions', 'vorrunde', 'preliminary_round', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_competitions', 'endrunde', 'final_round', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_competitions', 'max_teilnehmer', 'max_participants', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_competitions', 'startgeld', 'entry_fee', 'VARCHAR(255) NULL');
|
||||
|
||||
// official_tournaments
|
||||
await renameColumnIfExists('official_tournaments', 'termin', 'event_date', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_tournaments', 'veranstalter', 'organizer', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_tournaments', 'ausrichter', 'host', 'VARCHAR(255) NULL');
|
||||
await renameColumnIfExists('official_tournaments', 'austragungsorte', 'venues', 'TEXT NULL');
|
||||
await renameColumnIfExists('official_tournaments', 'konkurrenztypen', 'competition_types', 'TEXT NULL');
|
||||
await renameColumnIfExists('official_tournaments', 'meldeschluesse', 'registration_deadlines', 'TEXT NULL');
|
||||
|
||||
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}`);
|
||||
|
||||
@@ -33,11 +33,11 @@ const login = async (email, password) => {
|
||||
if (!user || !(await bcrypt.compare(password, user.password))) {
|
||||
throw { status: 401, message: 'Ungültige Anmeldedaten' };
|
||||
}
|
||||
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '3h' });
|
||||
await UserToken.create({
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
expiresAt: new Date(Date.now() + 3 * 3600 * 1000),
|
||||
});
|
||||
return { token };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import DiaryDateActivity from '../models/DiaryDateActivity.js';
|
||||
import GroupActivity from '../models/GroupActivity.js';
|
||||
import Group from '../models/Group.js';
|
||||
import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
@@ -12,17 +13,49 @@ class DiaryDateActivityService {
|
||||
await checkAccess(userToken, clubId);
|
||||
console.log('[DiaryDateActivityService::createActivity] - add: ', data);
|
||||
const { activity, ...restData } = data;
|
||||
let predefinedActivity = await PredefinedActivity.findOne({ where: { name: data.activity } });
|
||||
// Versuche, die PredefinedActivity robust zu finden:
|
||||
// 1) per übergebener ID
|
||||
// 2) per Name ODER Code (das Feld "activity" kann Kürzel oder Name sein)
|
||||
// 3) erst dann neu anlegen
|
||||
let predefinedActivity = null;
|
||||
|
||||
if (data.predefinedActivityId) {
|
||||
predefinedActivity = await PredefinedActivity.findByPk(data.predefinedActivityId);
|
||||
}
|
||||
|
||||
if (!predefinedActivity) {
|
||||
const normalized = (data.activity || '').trim();
|
||||
if (normalized) {
|
||||
predefinedActivity = await PredefinedActivity.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ name: normalized },
|
||||
{ code: normalized }
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!predefinedActivity) {
|
||||
predefinedActivity = await PredefinedActivity.create({
|
||||
name: data.activity,
|
||||
description: '',
|
||||
duration: data.duration
|
||||
name: data.name || data.activity || '',
|
||||
code: data.code || (data.activity || ''),
|
||||
description: data.description || '',
|
||||
duration: data.duration && data.duration !== '' ? parseInt(data.duration) : null
|
||||
});
|
||||
}
|
||||
restData.predefinedActivityId = predefinedActivity.id;
|
||||
restData.predefinedActivityId = predefinedActivity.id;
|
||||
|
||||
// Bereinige duration-Feld für DiaryDateActivity
|
||||
if (restData.duration === '' || restData.duration === undefined) {
|
||||
restData.duration = null;
|
||||
} else if (typeof restData.duration === 'string') {
|
||||
restData.duration = parseInt(restData.duration);
|
||||
}
|
||||
|
||||
const maxOrderId = await DiaryDateActivity.max('orderId', {
|
||||
where: { diaryDateId: data.diaryDateId }
|
||||
where: { diaryDateId: data.diaryDateId }
|
||||
});
|
||||
const newOrderId = maxOrderId !== null ? maxOrderId + 1 : 1;
|
||||
restData.orderId = newOrderId;
|
||||
@@ -39,7 +72,34 @@ class DiaryDateActivityService {
|
||||
console.log('[DiaryDateActivityService::updateActivity] - activity not found');
|
||||
throw new Error('Activity not found');
|
||||
}
|
||||
console.log('[DiaryDateActivityService::updateActivity] - update activity');
|
||||
|
||||
// Wenn customActivityName gesendet wird, müssen wir die PredefinedActivity behandeln
|
||||
if (data.customActivityName) {
|
||||
console.log('[DiaryDateActivityService::updateActivity] - handling customActivityName:', data.customActivityName);
|
||||
|
||||
// Suche nach einer existierenden PredefinedActivity mit diesem Namen
|
||||
let predefinedActivity = await PredefinedActivity.findOne({
|
||||
where: { name: data.customActivityName }
|
||||
});
|
||||
|
||||
if (!predefinedActivity) {
|
||||
// Erstelle eine neue PredefinedActivity
|
||||
console.log('[DiaryDateActivityService::updateActivity] - creating new PredefinedActivity');
|
||||
predefinedActivity = await PredefinedActivity.create({
|
||||
name: data.customActivityName,
|
||||
description: data.description || '',
|
||||
duration: data.duration && data.duration !== '' ? parseInt(data.duration) : (activity.duration || null)
|
||||
});
|
||||
}
|
||||
|
||||
// Setze die predefinedActivityId
|
||||
data.predefinedActivityId = predefinedActivity.id;
|
||||
|
||||
// Entferne customActivityName aus den zu aktualisierenden Daten
|
||||
delete data.customActivityName;
|
||||
}
|
||||
|
||||
console.log('[DiaryDateActivityService::updateActivity] - update activity', clubId, id, data, JSON.stringify(data));
|
||||
return await activity.update(data);
|
||||
}
|
||||
|
||||
@@ -104,9 +164,7 @@ class DiaryDateActivityService {
|
||||
}
|
||||
|
||||
async getActivities(userToken, clubId, diaryDateId) {
|
||||
console.log('[DiaryDateActivityService::getActivities] - check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
console.log(`[DiaryDateActivityService::getActivities] - fetch activities for diaryDateId: ${diaryDateId}`);
|
||||
const activities = await DiaryDateActivity.findAll({
|
||||
where: { diaryDateId },
|
||||
order: [['orderId', 'ASC']],
|
||||
@@ -114,6 +172,12 @@ class DiaryDateActivityService {
|
||||
{
|
||||
model: PredefinedActivity,
|
||||
as: 'predefinedActivity',
|
||||
include: [
|
||||
{
|
||||
model: PredefinedActivityImage,
|
||||
as: 'images'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: GroupActivity,
|
||||
@@ -131,8 +195,64 @@ class DiaryDateActivityService {
|
||||
}
|
||||
]
|
||||
});
|
||||
console.log(`[DiaryDateActivityService::getActivities] - found ${activities.length} activities`);
|
||||
return activities;
|
||||
|
||||
// Füge imageUrl zu jeder PredefinedActivity hinzu
|
||||
const activitiesWithImages = await Promise.all(activities.map(async activity => {
|
||||
// Konvertiere zu JSON und zurück, um alle Eigenschaften zu serialisieren
|
||||
const activityData = activity.toJSON();
|
||||
|
||||
if (activityData.predefinedActivity) {
|
||||
// Hole die erste verfügbare Image-ID direkt aus der Datenbank
|
||||
const allImages = await PredefinedActivityImage.findAll({
|
||||
where: { predefinedActivityId: activityData.predefinedActivity.id },
|
||||
order: [['createdAt', 'ASC']]
|
||||
});
|
||||
|
||||
const firstImage = allImages.length > 0 ? allImages[0] : null;
|
||||
|
||||
// Füge Zeichnungsdaten hinzu, falls vorhanden
|
||||
if (firstImage && firstImage.drawingData) {
|
||||
try {
|
||||
activityData.predefinedActivity.drawingData = JSON.parse(firstImage.drawingData);
|
||||
} catch (error) {
|
||||
console.error(`Activity ${activityData.predefinedActivity.id}: Error parsing drawingData:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (firstImage) {
|
||||
// Füge sowohl imageUrl als auch imageLink mit Image-ID hinzu
|
||||
activityData.predefinedActivity.imageUrl = `/api/predefined-activities/${activityData.predefinedActivity.id}/image/${firstImage.id}`;
|
||||
activityData.predefinedActivity.imageLink = `/api/predefined-activities/${activityData.predefinedActivity.id}/image/${firstImage.id}`;
|
||||
} else {
|
||||
// Fallback: Verwende den Basis-Pfad ohne Image-ID
|
||||
activityData.predefinedActivity.imageUrl = `/api/predefined-activities/${activityData.predefinedActivity.id}/image`;
|
||||
activityData.predefinedActivity.imageLink = `/api/predefined-activities/${activityData.predefinedActivity.id}/image`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auch für GroupActivities
|
||||
if (activityData.groupActivities && activityData.groupActivities.length > 0) {
|
||||
for (const groupActivity of activityData.groupActivities) {
|
||||
if (groupActivity.groupPredefinedActivity) {
|
||||
// Hole die erste verfügbare Image-ID direkt aus der Datenbank
|
||||
const firstImage = await PredefinedActivityImage.findOne({
|
||||
where: { predefinedActivityId: groupActivity.groupPredefinedActivity.id },
|
||||
order: [['createdAt', 'ASC']]
|
||||
});
|
||||
|
||||
if (firstImage) {
|
||||
groupActivity.groupPredefinedActivity.imageUrl = `/api/predefined-activities/${groupActivity.groupPredefinedActivity.id}/image/${firstImage.id}`;
|
||||
groupActivity.groupPredefinedActivity.imageLink = `/api/predefined-activities/${groupActivity.groupPredefinedActivity.id}/image/${firstImage.id}`;
|
||||
} else {
|
||||
groupActivity.groupPredefinedActivity.imageUrl = `/api/predefined-activities/${groupActivity.groupPredefinedActivity.id}/image`;
|
||||
groupActivity.groupPredefinedActivity.imageLink = `/api/predefined-activities/${groupActivity.groupPredefinedActivity.id}/image`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return activityData;
|
||||
}));
|
||||
return activitiesWithImages;
|
||||
}
|
||||
|
||||
async addGroupActivity(userToken, clubId, diaryDateId, groupId, activity) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import DiaryDate from '../models/DiaryDates.js';
|
||||
import DiaryDateActivity from '../models/DiaryDateActivity.js';
|
||||
import Club from '../models/Club.js';
|
||||
import DiaryNote from '../models/DiaryNote.js';
|
||||
import { DiaryTag } from '../models/DiaryTag.js';
|
||||
@@ -151,6 +152,23 @@ class DiaryService {
|
||||
await DiaryDateTag.destroy({ where: { tagId } });
|
||||
}
|
||||
|
||||
async removeDateForClub(userToken, clubId, dateId) {
|
||||
console.log('[DiaryService::removeDateForClub] - Check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
console.log('[DiaryService::removeDateForClub] - Validate date');
|
||||
const diaryDate = await DiaryDate.findOne({ where: { id: dateId, clubId } });
|
||||
if (!diaryDate) {
|
||||
throw new HttpError('Diary entry not found', 404);
|
||||
}
|
||||
console.log('[DiaryService::removeDateForClub] - Check for activities');
|
||||
const activityCount = await DiaryDateActivity.count({ where: { diaryDateId: dateId } });
|
||||
if (activityCount > 0) {
|
||||
throw new HttpError('Cannot delete date with activities', 409);
|
||||
}
|
||||
console.log('[DiaryService::removeDateForClub] - Delete diary date');
|
||||
await diaryDate.destroy();
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default new DiaryService();
|
||||
|
||||
@@ -54,7 +54,7 @@ class MemberService {
|
||||
}
|
||||
|
||||
async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, birthdate, phone, email, active = true, testMembership = false,
|
||||
picsInInternetAllowed = false) {
|
||||
picsInInternetAllowed = false, gender = 'unknown') {
|
||||
try {
|
||||
console.log('[setClubMembers] - Check access');
|
||||
await checkAccess(userToken, clubId);
|
||||
@@ -76,6 +76,7 @@ class MemberService {
|
||||
member.active = active;
|
||||
member.testMembership = testMembership;
|
||||
member.picsInInternetAllowed = picsInInternetAllowed;
|
||||
if (gender) member.gender = gender;
|
||||
await member.save();
|
||||
} else {
|
||||
await Member.create({
|
||||
@@ -90,6 +91,7 @@ class MemberService {
|
||||
active: active,
|
||||
testMembership: testMembership,
|
||||
picsInInternetAllowed: picsInInternetAllowed,
|
||||
gender: gender || 'unknown',
|
||||
});
|
||||
}
|
||||
console.log('[setClubMembers] - return response');
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import DiaryDateActivity from '../models/DiaryDateActivity.js';
|
||||
import GroupActivity from '../models/GroupActivity.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import sequelize from '../database.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
class PredefinedActivityService {
|
||||
async createPredefinedActivity(data) {
|
||||
console.log('[PredefinedActivityService::createPredefinedActivity] - Creating predefined activity');
|
||||
return await PredefinedActivity.create({
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
description: data.description,
|
||||
durationText: data.durationText,
|
||||
duration: data.duration,
|
||||
imageLink: data.imageLink,
|
||||
drawingData: data.drawingData ? JSON.stringify(data.drawingData) : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,15 +28,24 @@ class PredefinedActivityService {
|
||||
}
|
||||
return await activity.update({
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
description: data.description,
|
||||
durationText: data.durationText,
|
||||
duration: data.duration,
|
||||
imageLink: data.imageLink,
|
||||
drawingData: data.drawingData ? JSON.stringify(data.drawingData) : null,
|
||||
});
|
||||
}
|
||||
|
||||
async getAllPredefinedActivities() {
|
||||
console.log('[PredefinedActivityService::getAllPredefinedActivities] - Fetching all predefined activities');
|
||||
return await PredefinedActivity.findAll();
|
||||
return await PredefinedActivity.findAll({
|
||||
order: [
|
||||
[sequelize.literal('code IS NULL'), 'ASC'], // Non-null codes first
|
||||
['code', 'ASC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async getPredefinedActivityById(id) {
|
||||
@@ -40,6 +57,94 @@ class PredefinedActivityService {
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
async searchPredefinedActivities(query, limit = 20) {
|
||||
const q = (query || '').trim();
|
||||
if (!q || q.length < 2) {
|
||||
return [];
|
||||
}
|
||||
return await PredefinedActivity.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ name: { [Op.like]: `%${q}%` } },
|
||||
{ code: { [Op.like]: `%${q}%` } },
|
||||
],
|
||||
},
|
||||
order: [
|
||||
[sequelize.literal('code IS NULL'), 'ASC'],
|
||||
['code', 'ASC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
limit: Math.min(parseInt(limit || 20, 10), 50),
|
||||
});
|
||||
}
|
||||
|
||||
async mergeActivities(sourceId, targetId) {
|
||||
console.log(`[PredefinedActivityService::mergeActivities] - Merge ${sourceId} -> ${targetId}`);
|
||||
if (!sourceId || !targetId) throw new Error('sourceId and targetId are required');
|
||||
if (Number(sourceId) === Number(targetId)) throw new Error('sourceId and targetId must differ');
|
||||
|
||||
const tx = await sequelize.transaction();
|
||||
try {
|
||||
const source = await PredefinedActivity.findByPk(sourceId, { transaction: tx });
|
||||
const target = await PredefinedActivity.findByPk(targetId, { transaction: tx });
|
||||
if (!source) throw new Error('Source activity not found');
|
||||
if (!target) throw new Error('Target activity not found');
|
||||
|
||||
// Reassign references
|
||||
await DiaryDateActivity.update(
|
||||
{ predefinedActivityId: targetId },
|
||||
{ where: { predefinedActivityId: sourceId }, transaction: tx }
|
||||
);
|
||||
|
||||
await GroupActivity.update(
|
||||
{ customActivity: targetId },
|
||||
{ where: { customActivity: sourceId }, transaction: tx }
|
||||
);
|
||||
|
||||
await PredefinedActivityImage.update(
|
||||
{ predefinedActivityId: targetId },
|
||||
{ where: { predefinedActivityId: sourceId }, transaction: tx }
|
||||
);
|
||||
|
||||
// Finally delete source
|
||||
await source.destroy({ transaction: tx });
|
||||
|
||||
await tx.commit();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
await tx.rollback();
|
||||
console.error('[PredefinedActivityService::mergeActivities] - Error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deduplicateActivities() {
|
||||
console.log('[PredefinedActivityService::deduplicateActivities] - Start');
|
||||
const all = await PredefinedActivity.findAll();
|
||||
const nameToActivities = new Map();
|
||||
for (const activity of all) {
|
||||
const key = (activity.name || '').trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
if (!nameToActivities.has(key)) nameToActivities.set(key, []);
|
||||
nameToActivities.get(key).push(activity);
|
||||
}
|
||||
let mergedCount = 0;
|
||||
let groupCount = 0;
|
||||
for (const list of nameToActivities.values()) {
|
||||
if (!list || list.length <= 1) continue;
|
||||
groupCount++;
|
||||
// Stable target: kleinste ID
|
||||
list.sort((a, b) => a.id - b.id);
|
||||
const target = list[0];
|
||||
for (const src of list.slice(1)) {
|
||||
await this.mergeActivities(src.id, target.id);
|
||||
mergedCount++;
|
||||
}
|
||||
}
|
||||
console.log('[PredefinedActivityService::deduplicateActivities] - Done', { mergedCount, groupCount });
|
||||
return { mergedCount, groupCount };
|
||||
}
|
||||
}
|
||||
|
||||
export default new PredefinedActivityService();
|
||||
|
||||
@@ -180,32 +180,61 @@ class TournamentService {
|
||||
// 2) Alte Matches löschen
|
||||
await TournamentMatch.destroy({ where: { tournamentId } });
|
||||
|
||||
// 3) Shuffle + verteilen
|
||||
const shuffled = members.slice();
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
// 3) Prüfe, ob Spieler bereits manuell zugeordnet wurden
|
||||
const alreadyAssigned = members.filter(m => m.groupId !== null);
|
||||
const unassigned = members.filter(m => m.groupId === null);
|
||||
|
||||
if (alreadyAssigned.length > 0) {
|
||||
// Spieler sind bereits manuell zugeordnet - nicht neu verteilen
|
||||
console.log(`${alreadyAssigned.length} Spieler bereits zugeordnet, ${unassigned.length} noch nicht zugeordnet`);
|
||||
} else {
|
||||
// Keine manuellen Zuordnungen - zufällig verteilen
|
||||
const shuffled = members.slice();
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
groups.forEach((g, idx) => {
|
||||
shuffled
|
||||
.filter((_, i) => i % groups.length === idx)
|
||||
.forEach(m => m.update({ groupId: g.id }));
|
||||
});
|
||||
}
|
||||
groups.forEach((g, idx) => {
|
||||
shuffled
|
||||
.filter((_, i) => i % groups.length === idx)
|
||||
.forEach(m => m.update({ groupId: g.id }));
|
||||
});
|
||||
|
||||
// 4) Round‑Robin anlegen wie gehabt
|
||||
// 4) Round‑Robin anlegen wie gehabt - NUR innerhalb jeder Gruppe
|
||||
console.log(`[fillGroups] Erstelle Matches für ${groups.length} Gruppen`);
|
||||
for (const g of groups) {
|
||||
console.log(`[fillGroups] Verarbeite Gruppe ${g.id}`);
|
||||
const gm = await TournamentMember.findAll({ where: { groupId: g.id } });
|
||||
console.log(`[fillGroups] Gruppe ${g.id} hat ${gm.length} Teilnehmer:`, gm.map(m => ({ id: m.id, name: m.member?.firstName + ' ' + m.member?.lastName })));
|
||||
|
||||
if (gm.length < 2) {
|
||||
console.warn(`Gruppe ${g.id} hat nur ${gm.length} Teilnehmer - keine Matches erstellt`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rounds = this.generateRoundRobinSchedule(gm);
|
||||
console.log(`[fillGroups] Gruppe ${g.id} hat ${rounds.length} Runden`);
|
||||
|
||||
for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) {
|
||||
console.log(`[fillGroups] Runde ${roundIndex + 1} für Gruppe ${g.id}:`, rounds[roundIndex]);
|
||||
for (const [p1Id, p2Id] of rounds[roundIndex]) {
|
||||
await TournamentMatch.create({
|
||||
tournamentId,
|
||||
groupId: g.id,
|
||||
round: 'group',
|
||||
player1Id: p1Id,
|
||||
player2Id: p2Id,
|
||||
groupRound: roundIndex + 1
|
||||
});
|
||||
// Prüfe, ob beide Spieler zur gleichen Gruppe gehören
|
||||
const p1 = gm.find(p => p.id === p1Id);
|
||||
const p2 = gm.find(p => p.id === p2Id);
|
||||
if (p1 && p2 && p1.groupId === p2.groupId && p1.groupId === g.id) {
|
||||
const match = await TournamentMatch.create({
|
||||
tournamentId,
|
||||
groupId: g.id,
|
||||
round: 'group',
|
||||
player1Id: p1Id,
|
||||
player2Id: p2Id,
|
||||
groupRound: roundIndex + 1
|
||||
});
|
||||
console.log(`[fillGroups] Match erstellt: ${match.id} - Spieler ${p1Id} vs ${p2Id} in Gruppe ${g.id}`);
|
||||
} else {
|
||||
console.warn(`Spieler gehören nicht zur gleichen Gruppe: ${p1Id} (${p1?.groupId}) vs ${p2Id} (${p2?.groupId}) in Gruppe ${g.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -360,8 +389,13 @@ class TournamentService {
|
||||
for (const m of groupMatches.filter(m => m.groupId === g.id)) {
|
||||
if (!stats[m.player1Id] || !stats[m.player2Id]) continue;
|
||||
const [p1, p2] = m.result.split(":").map(n => parseInt(n, 10));
|
||||
if (p1 > p2) stats[m.player1Id].points += 2;
|
||||
else stats[m.player2Id].points += 2;
|
||||
if (p1 > p2) {
|
||||
stats[m.player1Id].points += 1; // Sieger bekommt +1
|
||||
stats[m.player2Id].points -= 1; // Verlierer bekommt -1
|
||||
} else {
|
||||
stats[m.player2Id].points += 1; // Sieger bekommt +1
|
||||
stats[m.player1Id].points -= 1; // Verlierer bekommt -1
|
||||
}
|
||||
stats[m.player1Id].setsWon += p1;
|
||||
stats[m.player1Id].setsLost += p2;
|
||||
stats[m.player2Id].setsWon += p2;
|
||||
|
||||
@@ -70,3 +70,13 @@ export const checkAccess = async (userToken, clubId) => {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkGlobalAccess = async (userToken) => {
|
||||
try {
|
||||
const user = await getUserByToken(userToken);
|
||||
return user; // Einfach den User zurückgeben, da globale Zugriffe nur Authentifizierung benötigen
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Trainingstagebuch</title>
|
||||
<title>Trainingstagebuch – Vereinsverwaltung, Trainingsplanung & Turniere</title>
|
||||
<meta name="description" content="Das TrainingsTagebuch hilft Vereinen und Trainer:innen, Mitglieder zu verwalten, Trainings zu dokumentieren, Spielpläne zu organisieren und Statistiken auszuwerten – alles in einer modernen Web‑App." />
|
||||
<link rel="canonical" href="https://tt-tagebuch.de/" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Trainingstagebuch" />
|
||||
<meta property="og:title" content="Trainingstagebuch – Vereinsverwaltung, Trainingsplanung & Turniere" />
|
||||
<meta property="og:description" content="Mitgliederverwaltung, Trainingstagebuch, Spiel‑ und Turnierorganisation sowie Statistiken – DSGVO‑freundlich und einfach." />
|
||||
<meta property="og:url" content="https://tt-tagebuch.de/" />
|
||||
<meta property="og:image" content="https://tt-tagebuch.de/vite.svg" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Trainingstagebuch – Vereinsverwaltung, Trainingsplanung & Turniere" />
|
||||
<meta name="twitter:description" content="Mitgliederverwaltung, Trainingstagebuch, Spiel‑ und Turnierorganisation sowie Statistiken – DSGVO‑freundlich und einfach." />
|
||||
<meta name="twitter:image" content="https://tt-tagebuch.de/vite.svg" />
|
||||
|
||||
<!-- JSON-LD: Website + Organization -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Trainingstagebuch",
|
||||
"url": "https://tt-tagebuch.de/",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": "https://tt-tagebuch.de/?q={search_term_string}",
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Trainingstagebuch",
|
||||
"applicationCategory": "SportsApplication",
|
||||
"operatingSystem": "Web",
|
||||
"description": "Mitgliederverwaltung, Trainingstagebuch, Spiel- und Turnierorganisation sowie Statistiken – DSGVO-freundlich und einfach.",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"url": "https://tt-tagebuch.de/"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
4
frontend/public/robots.txt
Normal file
4
frontend/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://tt-tagebuch.de/sitemap.xml
|
||||
28
frontend/public/sitemap.xml
Normal file
28
frontend/public/sitemap.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://tt-tagebuch.de/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://tt-tagebuch.de/register</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://tt-tagebuch.de/login</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://tt-tagebuch.de/impressum</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://tt-tagebuch.de/datenschutz</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div class="main">
|
||||
<header class="app-header">
|
||||
<h1>Trainingstagebuch</h1>
|
||||
<h1>
|
||||
<router-link to="/" class="home-link">
|
||||
<img :src="logoUrl" alt="Logo" class="home-logo" width="24" height="24" loading="lazy" />
|
||||
<span>Trainingstagebuch</span>
|
||||
</router-link>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="app-container">
|
||||
@@ -50,7 +55,15 @@
|
||||
</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>
|
||||
Offizielle Turniere
|
||||
</a>
|
||||
<a href="/predefined-activities" class="nav-link">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
Vordefinierte Aktivitäten
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -75,12 +88,20 @@
|
||||
<router-view class="content fade-in"></router-view>
|
||||
</main>
|
||||
</div>
|
||||
<footer class="app-footer">
|
||||
<div class="footer-content">
|
||||
<router-link to="/impressum" class="footer-link">Impressum</router-link>
|
||||
<span class="footer-sep">·</span>
|
||||
<router-link to="/datenschutz" class="footer-link">Datenschutzerklärung</router-link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import apiClient from './apiClient.js';
|
||||
import logoUrl from './assets/logo.png';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
@@ -88,6 +109,7 @@ export default {
|
||||
return {
|
||||
selectedClub: null,
|
||||
sessionInterval: null,
|
||||
logoUrl,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -104,10 +126,39 @@ export default {
|
||||
this.$router.push(`/showclub/${newVal}`);
|
||||
}
|
||||
},
|
||||
isAuthenticated(newVal) {
|
||||
if (newVal) {
|
||||
// Benutzer hat sich eingeloggt - Daten laden
|
||||
this.loadUserData();
|
||||
} else {
|
||||
// Benutzer hat sich ausgeloggt - Daten zurücksetzen
|
||||
this.setClubs([]);
|
||||
this.selectedClub = null;
|
||||
if (this.sessionInterval) {
|
||||
clearInterval(this.sessionInterval);
|
||||
this.sessionInterval = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['setCurrentClub', 'setClubs', 'logout']),
|
||||
|
||||
async loadUserData() {
|
||||
try {
|
||||
const response = await apiClient.get('/clubs');
|
||||
this.setClubs(response.data);
|
||||
if (this.currentClub) {
|
||||
this.selectedClub = this.currentClub;
|
||||
}
|
||||
this.checkSession();
|
||||
this.sessionInterval = setInterval(this.checkSession, 5000);
|
||||
} catch (error) {
|
||||
this.setClubs([]);
|
||||
this.selectedClub = null;
|
||||
}
|
||||
},
|
||||
|
||||
loadClub() {
|
||||
this.setCurrentClub(this.currentClub);
|
||||
this.$router.push(`/showclub/${this.currentClub}`);
|
||||
@@ -134,17 +185,20 @@ export default {
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await apiClient.get('/clubs');
|
||||
this.setClubs(response.data);
|
||||
if (this.currentClub) {
|
||||
this.selectedClub = this.currentClub;
|
||||
// Nur Daten laden, wenn der Benutzer authentifiziert ist
|
||||
if (this.isAuthenticated) {
|
||||
try {
|
||||
const response = await apiClient.get('/clubs');
|
||||
this.setClubs(response.data);
|
||||
if (this.currentClub) {
|
||||
this.selectedClub = this.currentClub;
|
||||
}
|
||||
this.checkSession();
|
||||
this.sessionInterval = setInterval(this.checkSession, 5000);
|
||||
} catch (error) {
|
||||
this.setClubs([]);
|
||||
this.selectedClub = null;
|
||||
}
|
||||
this.checkSession();
|
||||
this.sessionInterval = setInterval(this.checkSession, 5000);
|
||||
} catch (error) {
|
||||
this.setClubs([]);
|
||||
this.selectedClub = null;
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -189,6 +243,20 @@ export default {
|
||||
/* Schriftgröße bleibt wie in der main.scss definiert */
|
||||
}
|
||||
|
||||
.home-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.home-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* App Container */
|
||||
.app-container {
|
||||
display: flex;
|
||||
@@ -289,7 +357,7 @@ export default {
|
||||
text-decoration: none;
|
||||
border-radius: var(--border-radius-small);
|
||||
transition: all var(--transition-fast);
|
||||
font-size: 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
@@ -363,6 +431,30 @@ export default {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.app-footer {
|
||||
background: white;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.5rem;
|
||||
min-height: 100%;
|
||||
|
||||
@@ -19,4 +19,16 @@ apiClient.interceptors.request.use(config => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response-Interceptor für automatische Logout-Behandlung bei 401
|
||||
apiClient.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
// Automatisch ausloggen und zur Login-Seite weiterleiten
|
||||
store.dispatch('logout');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
|
||||
@@ -498,3 +498,34 @@
|
||||
padding: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Login und Registrierungslinks */
|
||||
.login-link,
|
||||
.register-link {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--background-light);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.login-link p,
|
||||
.register-link p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-link a,
|
||||
.register-link a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-link a:hover,
|
||||
.register-link a:hover {
|
||||
color: var(--primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Import der Komponenten */
|
||||
@import './components.scss';
|
||||
@use './components.scss' as *;
|
||||
|
||||
/* Modernes, frisches Design für TrainingsTagebuch */
|
||||
:root {
|
||||
@@ -369,8 +369,8 @@ th, td {
|
||||
th {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
text-transform: none;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
442
frontend/src/components/CourtDrawingRender.vue
Normal file
442
frontend/src/components/CourtDrawingRender.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="render-container">
|
||||
<canvas
|
||||
ref="canvas"
|
||||
:width="config.canvas.width"
|
||||
:height="config.canvas.height"
|
||||
style="margin:0 auto;"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CourtDrawingRender',
|
||||
props: {
|
||||
drawingData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 600
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 400
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
config: {
|
||||
// Mehr Platz links für die drei Startkreise (Rand außerhalb des Tisches)
|
||||
canvas: { width: 640, height: 400 },
|
||||
table: {
|
||||
// Tabelle etwas schmaler und weiter rechts platzieren
|
||||
width: 500,
|
||||
height: 300,
|
||||
color: '#2d5a2d',
|
||||
borderWidth: 2,
|
||||
borderColor: '#000000',
|
||||
outerFrameWidth: 5,
|
||||
outerFrameColor: '#ffffff',
|
||||
outerFrameBorderWidth: 0.5,
|
||||
outerFrameBorderColor: '#000000'
|
||||
},
|
||||
horizontalLine: { color: '#cfd8dc', width: 2, gap: 10, edgeMargin: 10 },
|
||||
net: { color: '#ffffff', width: 4, overhang: 14, borderColor: '#000000', borderWidth: 0.5 },
|
||||
serviceLine: { color: 'rgba(255,255,255,0.0)', width: 0 },
|
||||
startCircles: {
|
||||
radius: 10,
|
||||
// Abstand vom linken Canvas-Rand (größer = weiter links, da wir von tableX abziehen)
|
||||
x: 20,
|
||||
topYOffset: 35,
|
||||
bottomYOffset: 35,
|
||||
selectedColor: '#ff4444',
|
||||
unselectedColor: '#9e9e9e',
|
||||
selectedBorderColor: '#ffffff',
|
||||
unselectedBorderColor: '#555555',
|
||||
selectedBorderWidth: 2,
|
||||
unselectedBorderWidth: 1
|
||||
},
|
||||
arrows: {
|
||||
primaryColor: '#d32f2f', // rechts -> target (rot)
|
||||
secondaryColor: '#1565c0', // zurück (blau)
|
||||
width: 6,
|
||||
headLength: 24,
|
||||
vhOffsetX: 5,
|
||||
vhOffsetY: 8,
|
||||
rhOffsetX: 10,
|
||||
rhOffsetY: 0
|
||||
},
|
||||
targetCircles: {
|
||||
radius: 13,
|
||||
rightXOffset: 20,
|
||||
middleXOffset: 40,
|
||||
topYOffset: 45,
|
||||
bottomYOffset: 45,
|
||||
transparency: 0.25
|
||||
},
|
||||
leftTargetCircles: {
|
||||
radius: 10,
|
||||
leftXOffset: 20,
|
||||
rightXOffset: 40,
|
||||
topYOffset: 40,
|
||||
bottomYOffset: 40
|
||||
},
|
||||
hitMarker: {
|
||||
radius: 10.5,
|
||||
fill: '#ffffff',
|
||||
stroke: '#000000',
|
||||
lineWidth: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
drawingData: {
|
||||
handler() {
|
||||
this.redraw();
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// apply custom dimensions if provided
|
||||
this.config.canvas.width = this.width || 600;
|
||||
this.config.canvas.height = this.height || 400;
|
||||
this.$nextTick(() => {
|
||||
console.log('CourtDrawingRender: mounted with drawingData =', this.drawingData);
|
||||
this.init();
|
||||
this.redraw();
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.canvas = this.$refs.canvas;
|
||||
if (this.canvas) {
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
console.log('CourtDrawingRender: canvas/context initialized');
|
||||
}
|
||||
},
|
||||
redraw() {
|
||||
console.log('CourtDrawingRender: redraw called with data =', this.drawingData);
|
||||
if (!this.ctx) return;
|
||||
const { width, height } = this.config.canvas;
|
||||
// clear and background
|
||||
this.ctx.clearRect(0, 0, width, height);
|
||||
this.ctx.fillStyle = '#f0f0f0';
|
||||
this.ctx.fillRect(0, 0, width, height);
|
||||
this.drawTable();
|
||||
this.drawStartCircles();
|
||||
this.drawArrowsAndLabels();
|
||||
},
|
||||
drawTable() {
|
||||
const ctx = this.ctx;
|
||||
const { table, horizontalLine, net, outerFrameWidth } = {
|
||||
...this.config,
|
||||
outerFrameWidth: this.config.table.outerFrameWidth
|
||||
};
|
||||
const tableWidth = this.config.table.width;
|
||||
const tableHeight = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
|
||||
// table fill
|
||||
ctx.fillStyle = table.color;
|
||||
ctx.fillRect(tableX, tableY, tableWidth, tableHeight);
|
||||
|
||||
// table stroke
|
||||
ctx.strokeStyle = table.borderColor;
|
||||
ctx.lineWidth = table.borderWidth;
|
||||
ctx.strokeRect(tableX, tableY, tableWidth, tableHeight);
|
||||
|
||||
// outer white frame
|
||||
ctx.strokeStyle = table.outerFrameColor;
|
||||
ctx.lineWidth = table.outerFrameWidth;
|
||||
ctx.strokeRect(
|
||||
tableX - table.outerFrameWidth,
|
||||
tableY - table.outerFrameWidth,
|
||||
tableWidth + table.outerFrameWidth * 2,
|
||||
tableHeight + table.outerFrameWidth * 2
|
||||
);
|
||||
|
||||
// black thin frame around white
|
||||
ctx.strokeStyle = table.outerFrameBorderColor;
|
||||
ctx.lineWidth = table.outerFrameBorderWidth;
|
||||
ctx.strokeRect(
|
||||
tableX - table.outerFrameWidth - table.outerFrameBorderWidth,
|
||||
tableY - table.outerFrameWidth - table.outerFrameBorderWidth,
|
||||
tableWidth + (table.outerFrameWidth + table.outerFrameBorderWidth) * 2,
|
||||
tableHeight + (table.outerFrameWidth + table.outerFrameBorderWidth) * 2
|
||||
);
|
||||
|
||||
// horizontal split line (two segments)
|
||||
ctx.strokeStyle = horizontalLine.color;
|
||||
ctx.lineWidth = horizontalLine.width;
|
||||
const centerY = tableY + tableHeight / 2;
|
||||
const centerX = tableX + tableWidth / 2;
|
||||
const gap = horizontalLine.gap;
|
||||
const edge = horizontalLine.edgeMargin;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tableX + edge, centerY);
|
||||
ctx.lineTo(centerX - gap, centerY);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX + gap, centerY);
|
||||
ctx.lineTo(tableX + tableWidth - edge, centerY);
|
||||
ctx.stroke();
|
||||
|
||||
// net vertical
|
||||
ctx.strokeStyle = net.color;
|
||||
ctx.lineWidth = net.width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, tableY - net.overhang);
|
||||
ctx.lineTo(centerX, tableY + tableHeight + net.overhang);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = net.borderColor;
|
||||
ctx.lineWidth = net.borderWidth;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, tableY - net.overhang);
|
||||
ctx.lineTo(centerX, tableY + tableHeight + net.overhang);
|
||||
ctx.stroke();
|
||||
},
|
||||
drawStartCircles() {
|
||||
const ctx = this.ctx;
|
||||
const cfg = this.config.startCircles;
|
||||
const tableWidth = this.config.table.width;
|
||||
const tableHeight = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
|
||||
// Startkreis links VOR dem Tisch positionieren – nur den Kreis zeichnen,
|
||||
// von dem auch der rote Pfeil startet
|
||||
const circleX = tableX - cfg.x; // Links vom Tisch
|
||||
const topY = tableY + cfg.topYOffset;
|
||||
const midY = tableY + tableHeight / 2;
|
||||
const botY = tableY + tableHeight - cfg.bottomYOffset;
|
||||
|
||||
// Mapping und Fallback: bei "AS" auf 'middle'
|
||||
const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' };
|
||||
const selKey = this.drawingData?.selectedStartPosition || 'AS';
|
||||
const selectedPos = map[selKey] || 'middle';
|
||||
const y = selectedPos === 'top' ? topY : selectedPos === 'bottom' ? botY : midY;
|
||||
|
||||
ctx.fillStyle = cfg.selectedColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(circleX, y, cfg.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = cfg.selectedBorderColor;
|
||||
ctx.lineWidth = cfg.selectedBorderWidth;
|
||||
ctx.stroke();
|
||||
},
|
||||
computeRightTargetPosition(number) {
|
||||
// Geometrie wie im Tool: nutzt rightXOffset/middleXOffset
|
||||
const tableWidth = this.config.table.width;
|
||||
const tableHeight = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
const cfg = this.config.targetCircles;
|
||||
|
||||
const x1 = tableX + tableWidth - cfg.rightXOffset; // (1,2,3)
|
||||
const x3 = tableX + tableWidth / 2 + cfg.middleXOffset; // (7,8,9)
|
||||
const xdiff = x3 - x1;
|
||||
const x2 = x3 - xdiff / 2; // (4,5,6)
|
||||
|
||||
const positions = {
|
||||
1: { x: x1, y: tableY + cfg.topYOffset },
|
||||
2: { x: x1, y: tableY + tableHeight / 2 },
|
||||
3: { x: x1, y: tableY + tableHeight - cfg.bottomYOffset },
|
||||
4: { x: x2, y: tableY + cfg.topYOffset },
|
||||
5: { x: x2, y: tableY + tableHeight / 2 },
|
||||
6: { x: x2, y: tableY + tableHeight - cfg.bottomYOffset },
|
||||
7: { x: x3, y: tableY + cfg.topYOffset },
|
||||
8: { x: x3, y: tableY + tableHeight / 2 },
|
||||
9: { x: x3, y: tableY + tableHeight - cfg.bottomYOffset }
|
||||
};
|
||||
return positions[number] || null;
|
||||
},
|
||||
getStartPoint() {
|
||||
// Startpunkt wie im Tool abhängig von Schlagseite (VH/RH)
|
||||
const sc = this.config.startCircles;
|
||||
const ar = this.config.arrows;
|
||||
const tblW = this.config.table.width;
|
||||
const tblH = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tblW) / 2;
|
||||
const tableY = (this.config.canvas.height - tblH) / 2;
|
||||
const circleX = tableX - sc.x; // Kreis links vor dem Tisch
|
||||
const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' };
|
||||
const pos = map[this.drawingData?.selectedStartPosition] || 'middle';
|
||||
const y = pos === 'top' ? tableY + sc.topYOffset : pos === 'bottom' ? tableY + tblH - sc.bottomYOffset : tableY + tblH / 2;
|
||||
const isVH = (this.drawingData?.strokeType || 'VH') === 'VH';
|
||||
const startX = isVH
|
||||
? circleX + sc.radius + ar.vhOffsetX
|
||||
: circleX + sc.radius + ar.rhOffsetX;
|
||||
// VH: unterhalb seitlich (circleRadius + vhOffsetY), RH: rechts (rhOffsetY = 0)
|
||||
const startYOffset = isVH ? (sc.radius + ar.vhOffsetY) : ar.rhOffsetY;
|
||||
return { x: startX, y: y + startYOffset };
|
||||
},
|
||||
drawArrow(ctx, from, to, color, label) {
|
||||
const { width, headLength } = this.config.arrows;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
// line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.stroke();
|
||||
// arrow head
|
||||
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(to.x - headLength * Math.cos(angle - Math.PI / 6), to.y - headLength * Math.sin(angle - Math.PI / 6));
|
||||
ctx.lineTo(to.x - headLength * Math.cos(angle + Math.PI / 6), to.y - headLength * Math.sin(angle + Math.PI / 6));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
},
|
||||
drawLabelBelow(ctx, text, anchor) {
|
||||
if (!text) return;
|
||||
ctx.font = 'bold 13px Helvetica';
|
||||
const padding = 6;
|
||||
const yOffset = 23; // 5px tiefer
|
||||
const metrics = ctx.measureText(text);
|
||||
const textWidth = metrics.width;
|
||||
let x = anchor.x - textWidth / 2; // zentriert unter dem Punkt
|
||||
let y = anchor.y + yOffset;
|
||||
// clamp innerhalb Canvas
|
||||
x = Math.max(4, Math.min(this.config.canvas.width - textWidth - 4, x));
|
||||
y = Math.max(14, Math.min(this.config.canvas.height - 4, y));
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeText(text, x, y);
|
||||
ctx.fillText(text, x, y);
|
||||
},
|
||||
drawArrowsAndLabels() {
|
||||
if (!this.drawingData) return;
|
||||
const ctx = this.ctx;
|
||||
const from = this.getStartPoint();
|
||||
|
||||
// First arrow: to right target
|
||||
const tp = Number(this.drawingData.targetPosition);
|
||||
if (tp) {
|
||||
const to = this.computeRightTargetPosition(tp);
|
||||
// Zielmarkierung (unter dem Pfeilkopf)
|
||||
this.drawHitMarker(ctx, to);
|
||||
const strokeSide = this.drawingData.strokeType || '';
|
||||
const spinAbbrev = this.abbrevSpin(this.drawingData.spinType);
|
||||
// Text gehört an die Quelle (ohne "target")
|
||||
const sourceLabel = `${strokeSide} ${spinAbbrev}`.trim();
|
||||
const toEnd = { x: to.x - this.config.targetCircles.radius, y: to.y };
|
||||
this.drawArrow(ctx, from, toEnd, this.config.arrows.primaryColor);
|
||||
// Unter dem Startkreis beschriften
|
||||
const startCenter = this.getStartCircleCenter();
|
||||
this.drawLabelBelow(ctx, sourceLabel, startCenter);
|
||||
}
|
||||
|
||||
// Second arrow (optional): from right source to left target
|
||||
const leftTarget = this.drawingData.nextStrokeTargetPosition ? Number(this.drawingData.nextStrokeTargetPosition) : null;
|
||||
if (tp && leftTarget) {
|
||||
// source near previous right target
|
||||
const sourceRightCenter = this.computeRightTargetPosition(tp);
|
||||
// left target mapping: mirror scheme to left half
|
||||
const toLeftCenter = this.computeLeftTargetPosition(leftTarget);
|
||||
// Zielmarkierung links
|
||||
this.drawHitMarker(ctx, toLeftCenter);
|
||||
const side = this.drawingData.nextStrokeSide || '';
|
||||
const type = this.drawingData.nextStrokeType || '';
|
||||
// Text gehört ans Ziel (ohne "extra target")
|
||||
const targetLabel = `${side} ${type}`.trim();
|
||||
const sourceRight = { x: sourceRightCenter.x - this.config.targetCircles.radius, y: sourceRightCenter.y };
|
||||
const toLeft = { x: toLeftCenter.x + this.config.leftTargetCircles.radius, y: toLeftCenter.y };
|
||||
this.drawArrow(ctx, sourceRight, toLeft, this.config.arrows.secondaryColor);
|
||||
// Unter dem rechten Ziel (target der ersten Linie) beschriften
|
||||
this.drawLabelBelow(ctx, targetLabel, sourceRightCenter);
|
||||
}
|
||||
},
|
||||
getStartCircleCenter() {
|
||||
const cfg = this.config.startCircles;
|
||||
const tableWidth = this.config.table.width;
|
||||
const tableHeight = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
const circleX = tableX - cfg.x;
|
||||
const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' };
|
||||
const selKey = this.drawingData?.selectedStartPosition || 'AS';
|
||||
const selectedPos = map[selKey] || 'middle';
|
||||
const topY = tableY + cfg.topYOffset;
|
||||
const midY = tableY + tableHeight / 2;
|
||||
const botY = tableY + tableHeight - cfg.bottomYOffset;
|
||||
const y = selectedPos === 'top' ? topY : selectedPos === 'bottom' ? botY : midY;
|
||||
return { x: circleX, y };
|
||||
},
|
||||
drawHitMarker(ctx, pos) {
|
||||
if (!pos) return;
|
||||
const mk = this.config.hitMarker;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, mk.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = mk.fill;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = mk.lineWidth;
|
||||
ctx.strokeStyle = mk.stroke;
|
||||
ctx.stroke();
|
||||
},
|
||||
computeLeftTargetPosition(number) {
|
||||
// Spiegelung wie im Tool: nutzt leftTargetCircles Offsets
|
||||
const tableWidth = this.config.table.width;
|
||||
const tableHeight = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
const cfg = this.config.leftTargetCircles;
|
||||
|
||||
const x1 = tableX + cfg.leftXOffset; // linke Spalte (Lang)
|
||||
const x3 = tableX + tableWidth / 2 - cfg.rightXOffset; // nah am Netz (Kurz)
|
||||
const xdiff = x3 - x1;
|
||||
const x2 = x3 - xdiff / 2;
|
||||
|
||||
// Gespiegelte Y-Zuordnung wie im Tool:
|
||||
// 1,4,7 = unten (VH gespiegelt)
|
||||
// 2,5,8 = mitte
|
||||
// 3,6,9 = oben (RH gespiegelt)
|
||||
const positions = {
|
||||
1: { x: x1, y: tableY + tableHeight - cfg.bottomYOffset },
|
||||
2: { x: x1, y: tableY + tableHeight / 2 },
|
||||
3: { x: x1, y: tableY + cfg.topYOffset },
|
||||
4: { x: x2, y: tableY + tableHeight - cfg.bottomYOffset },
|
||||
5: { x: x2, y: tableY + tableHeight / 2 },
|
||||
6: { x: x2, y: tableY + cfg.topYOffset },
|
||||
7: { x: x3, y: tableY + tableHeight - cfg.bottomYOffset },
|
||||
8: { x: x3, y: tableY + tableHeight / 2 },
|
||||
9: { x: x3, y: tableY + cfg.topYOffset }
|
||||
};
|
||||
return positions[number] || null;
|
||||
},
|
||||
abbrevSpin(spin) {
|
||||
if (!spin) return '';
|
||||
const map = {
|
||||
Unterschnitt: 'US',
|
||||
Überschnitt: 'OS',
|
||||
Seitschnitt: 'SR',
|
||||
Seitunterschnitt: 'SU',
|
||||
Gegenläufer: 'GL'
|
||||
};
|
||||
return map[spin] || spin;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.render-container {
|
||||
width: 100%;
|
||||
}
|
||||
canvas { display: block; max-width: 100%; height: auto; }
|
||||
</style>
|
||||
|
||||
|
||||
1618
frontend/src/components/CourtDrawingTool.vue
Normal file
1618
frontend/src/components/CourtDrawingTool.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -264,6 +264,226 @@ class PDFGenerator {
|
||||
});
|
||||
}
|
||||
|
||||
addParticipantsSummary(tournamentTitle, tournamentDateText, groups) {
|
||||
// Header
|
||||
const title = tournamentTitle || 'Offizielles Turnier';
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(14);
|
||||
this.pdf.text(title, this.margin, this.cursorY);
|
||||
this.cursorY += 8;
|
||||
if (tournamentDateText) {
|
||||
this.pdf.setFont('helvetica', 'normal');
|
||||
this.pdf.setFontSize(12);
|
||||
this.pdf.text(String(tournamentDateText), this.margin, this.cursorY);
|
||||
this.cursorY += 8;
|
||||
}
|
||||
|
||||
// Tabelle mit Gruppierung
|
||||
const head = [['Mitglied', 'Konkurrenz', 'Startzeit', 'Status', 'Platzierung']];
|
||||
const body = [];
|
||||
const rowStyles = [];
|
||||
|
||||
for (const group of groups) {
|
||||
for (let i = 0; i < group.items.length; i++) {
|
||||
const item = group.items[i];
|
||||
const rowData = [
|
||||
i === 0 ? group.memberName : '', // Name nur in erster Zeile
|
||||
item.competitionName,
|
||||
item.start || '–',
|
||||
item.statusText || '',
|
||||
item.placement || ''
|
||||
];
|
||||
body.push(rowData);
|
||||
rowStyles.push({
|
||||
isFirstRow: i === 0,
|
||||
memberStyle: group.memberStyle,
|
||||
competitionName: item.competitionName,
|
||||
statusStyle: item.statusStyle
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.pdf.setFontSize(11);
|
||||
autoTable(this.pdf, {
|
||||
startY: this.cursorY,
|
||||
margin: { left: this.margin, right: this.margin },
|
||||
head,
|
||||
body,
|
||||
theme: 'grid',
|
||||
styles: { fontSize: 11 },
|
||||
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left' },
|
||||
didParseCell: (data) => {
|
||||
if (data.section !== 'body') return;
|
||||
const rowStyle = rowStyles[data.row.index];
|
||||
|
||||
// Formatierung für Mitgliedsname (erste Spalte, erste Zeile der Gruppe)
|
||||
if (data.column.index === 0 && rowStyle.isFirstRow) {
|
||||
if (rowStyle.memberStyle === 'bold') data.cell.styles.fontStyle = 'bold';
|
||||
else if (rowStyle.memberStyle === 'italic') data.cell.styles.fontStyle = 'italic';
|
||||
else data.cell.styles.fontStyle = 'normal';
|
||||
}
|
||||
// Formatierung für Konkurrenzname (zweite Spalte)
|
||||
else if (data.column.index === 1) {
|
||||
if (rowStyle.statusStyle === 'bold') data.cell.styles.fontStyle = 'bold';
|
||||
else if (rowStyle.statusStyle === 'italic') data.cell.styles.fontStyle = 'italic';
|
||||
else data.cell.styles.fontStyle = 'normal';
|
||||
}
|
||||
},
|
||||
didDrawPage: (data) => {
|
||||
this.cursorY = data.cursor.y + 10;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addMemberCompetitions(tournamentTitle, memberName, recommendedRows = [], otherRows = [], venues = []) {
|
||||
let y = this.margin;
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(14);
|
||||
this.pdf.text(tournamentTitle || 'Offizielles Turnier', this.margin, y);
|
||||
y += 9;
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(12);
|
||||
this.pdf.text(`Mitglied: ${memberName}`, this.margin, y);
|
||||
y += 8;
|
||||
// Empfehlungen (fett)
|
||||
if (recommendedRows && recommendedRows.length) {
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(13);
|
||||
this.pdf.text('Empfehlungen', this.margin, y);
|
||||
y += 7;
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(12);
|
||||
this.pdf.text('Wettbewerb', this.margin, y);
|
||||
this.pdf.text('Datum', this.margin + 80, y);
|
||||
this.pdf.text('Startzeit', this.margin + 120, y);
|
||||
this.pdf.text('Gebühr', this.margin + 160, y);
|
||||
y += 7;
|
||||
for (const r of recommendedRows) {
|
||||
this.pdf.text(r.name || '', this.margin, y);
|
||||
this.pdf.text(r.date || '–', this.margin + 80, y);
|
||||
this.pdf.text(r.time || '–', this.margin + 120, y);
|
||||
this.pdf.text(r.entryFee || '–', this.margin + 160, y);
|
||||
y += 7;
|
||||
if (y > this.pageHeight) {
|
||||
this.addNewPage();
|
||||
y = this.margin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weitere spielbare Wettbewerbe (normal)
|
||||
if (otherRows && otherRows.length) {
|
||||
y += 5;
|
||||
if (y > this.pageHeight) { this.addNewPage(); y = this.margin; }
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(13);
|
||||
this.pdf.text('Ebenfalls spielbar', this.margin, y);
|
||||
y += 7;
|
||||
this.pdf.setFont('helvetica', 'normal');
|
||||
this.pdf.setFontSize(12);
|
||||
for (const r of otherRows) {
|
||||
this.pdf.text(r.name || '', this.margin, y);
|
||||
this.pdf.text(r.date || '–', this.margin + 80, y);
|
||||
this.pdf.text(r.time || '–', this.margin + 120, y);
|
||||
this.pdf.text(r.entryFee || '–', this.margin + 160, y);
|
||||
y += 7;
|
||||
if (y > this.pageHeight) {
|
||||
this.addNewPage();
|
||||
y = this.margin;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
this.addNewPage();
|
||||
y = this.margin;
|
||||
} else {
|
||||
y += 6;
|
||||
}
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(13);
|
||||
this.pdf.text('Hinweise:', this.margin, y);
|
||||
y += 7;
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(12);
|
||||
const maxWidth = 210 - this.margin * 2;
|
||||
const bullets = [
|
||||
'Eine Stunde vor Beginn der Konkurrenz in der Halle sein',
|
||||
'Kein weißes Trikot',
|
||||
'Sportshorts (oder Sportröckchen), am besten auch nicht weiß',
|
||||
'Hallenschuhe (dürfen auf Boden nicht abfärben)',
|
||||
'Eine Flasche Wasser dabei haben',
|
||||
'Da der Verein die Meldung übernehmen möchte, die Trainer mind. eine Woche vor dem Turnier über die Teilnahme informieren',
|
||||
];
|
||||
for (const b of bullets) {
|
||||
const lines = this.pdf.splitTextToSize(`- ${b}`, maxWidth);
|
||||
for (const line of lines) {
|
||||
this.pdf.text(line, this.margin, y);
|
||||
y += 6;
|
||||
if (y > this.pageHeight) {
|
||||
this.addNewPage();
|
||||
y = this.margin;
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(12);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Leerzeile vor dem Abschlusssatz
|
||||
if (y + 6 > this.pageHeight) {
|
||||
this.addNewPage();
|
||||
y = this.margin;
|
||||
} else {
|
||||
y += 6;
|
||||
}
|
||||
const finalLine = 'Die Trainer probieren bei allen Turnieren anwesend zu sein.';
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(12);
|
||||
const finalLines = this.pdf.splitTextToSize(finalLine, maxWidth);
|
||||
for (const line of finalLines) {
|
||||
this.pdf.text(line, this.margin, y);
|
||||
y += 6;
|
||||
if (y > this.pageHeight) {
|
||||
this.addNewPage();
|
||||
y = this.margin;
|
||||
this.pdf.setFont('helvetica', 'bold');
|
||||
this.pdf.setFontSize(12);
|
||||
}
|
||||
}
|
||||
this.cursorY = y + 10;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default PDFGenerator;
|
||||
|
||||
@@ -11,6 +11,10 @@ import PendingApprovalsView from './views/PendingApprovalsView.vue';
|
||||
import ScheduleView from './views/ScheduleView.vue';
|
||||
import TournamentsView from './views/TournamentsView.vue';
|
||||
import TrainingStatsView from './views/TrainingStatsView.vue';
|
||||
import PredefinedActivities from './views/PredefinedActivities.vue';
|
||||
import OfficialTournaments from './views/OfficialTournaments.vue';
|
||||
import Impressum from './views/Impressum.vue';
|
||||
import Datenschutz from './views/Datenschutz.vue';
|
||||
|
||||
const routes = [
|
||||
{ path: '/register', component: Register },
|
||||
@@ -25,6 +29,10 @@ const routes = [
|
||||
{ path: '/schedule', component: ScheduleView},
|
||||
{ path: '/tournaments', component: TournamentsView },
|
||||
{ path: '/training-stats', component: TrainingStatsView },
|
||||
{ path: '/predefined-activities', component: PredefinedActivities },
|
||||
{ path: '/official-tournaments', component: OfficialTournaments },
|
||||
{ path: '/impressum', component: Impressum },
|
||||
{ path: '/datenschutz', component: Datenschutz },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -55,7 +55,7 @@ const store = createStore({
|
||||
commit('clearToken');
|
||||
commit('clearUsername');
|
||||
router.push('/login'); // Leitet den Benutzer zur Login-Seite um
|
||||
window.location.reload(); // Optional, um den Zustand vollständig zurückzusetzen
|
||||
// window.location.reload() entfernt, um Endlos-Neuladeschleife zu verhindern
|
||||
},
|
||||
|
||||
setCurrentClub({ commit }, club) {
|
||||
|
||||
@@ -8,8 +8,13 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3>Mitglieder</h3>
|
||||
<ul>
|
||||
<li v-for="member in club.members" :key="member.id">{{ member.lastName }}, {{ member.firstName }}</li>
|
||||
<ul class="members">
|
||||
<li v-for="member in displayedMembers" :key="member.id" class="member-item">
|
||||
<span class="gender-symbol" :class="'gender-' + (member.gender || 'unknown')" :title="labelGender(member.gender)">{{ genderSymbol(member.gender) }}</span>
|
||||
<span class="gender-name" :class="['gender-' + (member.gender || 'unknown'), { 'is-test': member.testMembership }]">
|
||||
{{ member.lastName }}, {{ member.firstName }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -64,6 +69,40 @@ export default {
|
||||
if (response.status === 200) {
|
||||
alert('Zugriff wurde angefragt');
|
||||
}
|
||||
},
|
||||
labelGender(g) {
|
||||
const v = (g || 'unknown');
|
||||
if (v === 'male') return 'Männlich';
|
||||
if (v === 'female') return 'Weiblich';
|
||||
if (v === 'diverse') return 'Divers';
|
||||
return 'Unbekannt';
|
||||
},
|
||||
genderSymbol(g) {
|
||||
const v = (g || 'unknown');
|
||||
if (v === 'male') return '♂';
|
||||
if (v === 'female') return '♀';
|
||||
if (v === 'diverse') return '⚧';
|
||||
return '';
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isAuthenticated', 'currentClub', 'clubs']),
|
||||
displayedMembers() {
|
||||
const members = Array.isArray(this.club.members) ? this.club.members : [];
|
||||
const onlyActive = members.filter(m => m && (m.active === true));
|
||||
return onlyActive.sort((a, b) => {
|
||||
const lnA = (a.lastName || '').toLowerCase();
|
||||
const lnB = (b.lastName || '').toLowerCase();
|
||||
if (lnA && !lnB) return -1;
|
||||
if (!lnA && lnB) return 1;
|
||||
if (lnA && lnB) {
|
||||
const cmp = lnA.localeCompare(lnB, 'de-DE');
|
||||
if (cmp !== 0) return cmp;
|
||||
}
|
||||
const fnA = (a.firstName || '').toLowerCase();
|
||||
const fnB = (b.firstName || '').toLowerCase();
|
||||
return fnA.localeCompare(fnB, 'de-DE');
|
||||
});
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
@@ -83,4 +122,18 @@ ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.members { margin-top: .25rem; }
|
||||
.member-item { padding: .15rem 0; }
|
||||
.gender-symbol, .gender-name { background: transparent; border: none; }
|
||||
.gender-name.gender-male { color: #1a73e8; }
|
||||
.gender-name.gender-female { color: #d81b60; }
|
||||
.gender-name.gender-diverse { color: #6a1b9a; }
|
||||
.gender-name.gender-unknown { color: #444; }
|
||||
.gender-symbol.gender-male { color: #1a73e8; }
|
||||
.gender-symbol.gender-female { color: #d81b60; }
|
||||
.gender-symbol.gender-diverse { color: #6a1b9a; }
|
||||
.gender-symbol.gender-unknown { color: #444; }
|
||||
.gender-symbol { margin-right: .35rem; opacity: .9; font-size: 1.05em; display: inline-block; width: 1.1em; text-align: center; }
|
||||
.is-test { font-style: italic; }
|
||||
</style>
|
||||
|
||||
119
frontend/src/views/Datenschutz.vue
Normal file
119
frontend/src/views/Datenschutz.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>Datenschutzerklärung</h1>
|
||||
<p class="back-home"><router-link to="/">Zur Startseite</router-link></p>
|
||||
|
||||
<section>
|
||||
<h2>1. Verantwortlicher</h2>
|
||||
<p>
|
||||
Torsten Schulz<br/>
|
||||
Friedrich-Stampfer-Str. 21<br/>
|
||||
60437 Frankfurt, Deutschland<br/>
|
||||
E-Mail: <a href="mailto:tsschulz@tsschulz.de">tsschulz@tsschulz.de</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Zwecke und Rechtsgrundlagen der Verarbeitung</h2>
|
||||
<ul>
|
||||
<li><strong>Bereitstellung der Website</strong> (Server-Logs, Sicherheit, Stabilität) – Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO.</li>
|
||||
<li><strong>Nutzung des TrainingsTagebuchs</strong> (Registrierung, Login, Vereinsverwaltung) – Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO (Vertrag/vertragsähnliches Verhältnis).</li>
|
||||
<li><strong>Einwilligungsbasierte Vorgänge</strong> (z. B. optionale Funktionen) – Rechtsgrundlage: Art. 6 Abs. 1 lit. a DSGVO.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Kategorien personenbezogener Daten</h2>
|
||||
<ul>
|
||||
<li><strong>Nutzungsdaten</strong>: IP-Adresse, Datum/Uhrzeit, abgerufene Inhalte, User-Agent (Server-Logfiles).</li>
|
||||
<li><strong>Registrierungs-/Profildaten</strong>: Benutzername, E-Mail-Adresse (und ggf. weitere durch den Nutzer bereitgestellte Angaben).</li>
|
||||
<li><strong>Vereins-/Aktivitätsdaten</strong>: Inhalte, die Nutzer im Rahmen der Anwendung anlegen (z. B. Mitglieder-/Trainingsdaten).</li>
|
||||
<li><strong>Cookies/Local Storage</strong>: technisch notwendige Informationen (z. B. Session-/Auth-Token).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Empfänger</h2>
|
||||
<p>
|
||||
Eine Weitergabe erfolgt nur, soweit dies zur Bereitstellung der Website und Funktionen notwendig ist (z. B. Hosting/Technik) oder eine rechtliche Verpflichtung besteht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Drittlandübermittlung</h2>
|
||||
<p>
|
||||
Eine Übermittlung in Drittländer findet grundsätzlich nicht statt, es sei denn, dies ist zur Nutzung einzelner Dienste technisch erforderlich. In solchen Fällen wird auf geeignete Garantien geachtet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Speicherdauer</h2>
|
||||
<p>
|
||||
Personenbezogene Daten werden nur so lange gespeichert, wie es für die jeweiligen Zwecke erforderlich ist bzw. gesetzliche Aufbewahrungspflichten bestehen. Server-Logdaten werden in der Regel kurzfristig gelöscht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Rechte der betroffenen Personen</h2>
|
||||
<ul>
|
||||
<li>Auskunft (Art. 15 DSGVO)</li>
|
||||
<li>Berichtigung (Art. 16 DSGVO)</li>
|
||||
<li>Löschung (Art. 17 DSGVO)</li>
|
||||
<li>Einschränkung (Art. 18 DSGVO)</li>
|
||||
<li>Datenübertragbarkeit (Art. 20 DSGVO)</li>
|
||||
<li>Widerspruch (Art. 21 DSGVO)</li>
|
||||
<li>Widerruf erteilter Einwilligungen (Art. 7 Abs. 3 DSGVO)</li>
|
||||
</ul>
|
||||
<p>
|
||||
Zudem besteht ein Beschwerderecht bei einer Aufsichtsbehörde (Art. 77 DSGVO), z. B. beim HBDI in Hessen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Erforderlichkeit der Bereitstellung</h2>
|
||||
<p>
|
||||
Für die Nutzung des TrainingsTagebuchs sind bestimmte Angaben erforderlich (z. B. E-Mail und Login-Daten). Ohne diese ist eine Registrierung/Anmeldung nicht möglich.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. Cookies</h2>
|
||||
<p>
|
||||
Es werden vorwiegend technisch notwendige Cookies bzw. Webspeicher (Local Storage/Session Storage) verwendet, um die Anmeldung und Sitzungen zu ermöglichen. Eine Nutzung findet ohne Tracking zu Werbezwecken statt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10. Stand</h2>
|
||||
<p>
|
||||
Diese Datenschutzerklärung ist aktuell und wird bei Bedarf angepasst.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Datenschutz',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
section + section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.back-home {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,21 +6,144 @@
|
||||
<h2 class="card-title">Willkommen im TrainingsTagebuch</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="!isAuthenticated" class="auth-message">
|
||||
<p class="message-text">
|
||||
Melde dich an, um deine Vereine und Trainingsaktivitäten zu verwalten.
|
||||
</p>
|
||||
<div class="auth-actions">
|
||||
<router-link to="/login" class="btn-primary">
|
||||
<span class="btn-icon">🔐</span>
|
||||
Einloggen
|
||||
<div v-if="!isAuthenticated" class="marketing">
|
||||
<section class="hero">
|
||||
<h1 class="hero-title">Vereinsverwaltung, Trainingsplanung und Turniere – alles an einem Ort</h1>
|
||||
<p class="hero-subtitle">
|
||||
Das TrainingsTagebuch hilft Vereinen und Trainerinnen/Trainern, Mitglieder zu verwalten, Trainings zu dokumentieren,
|
||||
Spielpläne zu organisieren und Ergebnisse auszuwerten – DSGVO‑konform und einfach zu bedienen.
|
||||
</p>
|
||||
<div class="auth-actions">
|
||||
<router-link to="/register" class="btn-primary">
|
||||
<span class="btn-icon">🚀</span>
|
||||
Kostenlos starten
|
||||
</router-link>
|
||||
<router-link to="/login" class="btn-secondary">
|
||||
<span class="btn-icon">🔐</span>
|
||||
Einloggen
|
||||
</router-link>
|
||||
</div>
|
||||
<ul class="hero-bullets">
|
||||
<li>✔️ Mitglieder- und Gruppenverwaltung</li>
|
||||
<li>✔️ Trainings‑ und Turnierplanung</li>
|
||||
<li>✔️ Trainingsstatistiken und Auswertungen</li>
|
||||
<li>✔️ Rollen, Freigaben und sichere Zugriffe</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="features-section">
|
||||
<h3 class="section-title">Was kannst du mit dem TrainingsTagebuch machen?</h3>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card card">
|
||||
<div class="feature-icon">👥</div>
|
||||
<h4 class="feature-title">Mitglieder verwalten</h4>
|
||||
<p class="feature-description">
|
||||
Erstelle Mitgliedsprofile, bilde Gruppen und halte Kontakt‑ und Freigabestände aktuell.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card card">
|
||||
<div class="feature-icon">📝</div>
|
||||
<h4 class="feature-title">Trainingstagebuch führen</h4>
|
||||
<p class="feature-description">
|
||||
Dokumentiere Inhalte, Umfang und Anwesenheiten jeder Einheit – nachvollziehbar und strukturiert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card card">
|
||||
<div class="feature-icon">📅</div>
|
||||
<h4 class="feature-title">Spielpläne organisieren</h4>
|
||||
<p class="feature-description">
|
||||
Plane Spiele, Turniere und Veranstaltungen inklusive Gruppen, Runden und Ergebnissen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h4 class="feature-title">Statistiken & Auswertung</h4>
|
||||
<p class="feature-description">
|
||||
Erhalte Trainings‑ und Teilnahmeübersichten, erkenne Entwicklung und plane gezielt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card card">
|
||||
<div class="feature-icon">🔒</div>
|
||||
<h4 class="feature-title">Sicherheit & DSGVO</h4>
|
||||
<p class="feature-description">
|
||||
Datenschutzfreundliche Architektur, Freigaben durch Mitglieder und transparente Zugriffe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card card">
|
||||
<div class="feature-icon">⚙️</div>
|
||||
<h4 class="feature-title">Vordefinierte Aktivitäten</h4>
|
||||
<p class="feature-description">
|
||||
Nutze Vorlagen für wiederkehrende Übungen und beschleunige deine Dokumentation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="how-it-works">
|
||||
<h3 class="section-title">So funktioniert es</h3>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-number">1</div>
|
||||
<h4 class="step-title">Registrieren</h4>
|
||||
<p>Lege kostenlos einen Account an und aktiviere ihn per E‑Mail.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<h4 class="step-title">Verein anlegen</h4>
|
||||
<p>Erstelle deinen Verein, lade Mitglieder ein und richte Gruppen ein.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">3</div>
|
||||
<h4 class="step-title">Planen & dokumentieren</h4>
|
||||
<p>Plane Termine, dokumentiere Trainings und verfolge Fortschritte.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="seo-copy">
|
||||
<h3 class="section-title">Für wen ist das TrainingsTagebuch?</h3>
|
||||
<p class="long-text">
|
||||
Das TrainingsTagebuch ist die zentrale Plattform für Vereine, Abteilungen und Trainerteams.
|
||||
Es vereint Mitgliederverwaltung, Trainingsplanung, Spiel‑ und Turnierorganisation sowie aussagekräftige
|
||||
Statistiken in einer modernen Web‑Anwendung. Durch klare Rollen und Freigaben behalten Verantwortliche die
|
||||
Kontrolle, während Mitglieder selbstbestimmt mitwirken können. Ideal für Mannschafts‑, Racket‑ und
|
||||
Individualsportarten – vom Nachwuchs bis zum Leistungsbereich.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="faq">
|
||||
<h3 class="section-title">Häufige Fragen</h3>
|
||||
<details>
|
||||
<summary>Ist die Nutzung kostenlos?</summary>
|
||||
<p>Ja, du kannst kostenlos starten. Erweiterungen können später folgen.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Wie steht es um den Datenschutz?</summary>
|
||||
<p>Wir setzen auf Datensparsamkeit, transparente Freigaben und rollenbasierte Zugriffe.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Benötige ich eine Installation?</summary>
|
||||
<p>Nein, es handelt sich um eine Web‑Anwendung. Du nutzt sie direkt im Browser.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<div class="cta-bottom">
|
||||
<router-link to="/register" class="btn-primary">
|
||||
<span class="btn-icon">✅</span>
|
||||
Jetzt kostenlos registrieren
|
||||
</router-link>
|
||||
<router-link to="/register" class="btn-secondary">
|
||||
<span class="btn-icon">📝</span>
|
||||
Registrieren
|
||||
<router-link to="/login" class="btn-secondary">
|
||||
<span class="btn-icon">🔐</span>
|
||||
Ich habe schon einen Account
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="user-welcome">
|
||||
<div class="user-avatar">
|
||||
<span class="avatar-icon">👋</span>
|
||||
@@ -121,6 +244,46 @@ export default {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.marketing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.875rem;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
margin: 0.25rem 0 0.25rem 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 780px;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-bullets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.5rem 1rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.75rem 0 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.message-text, .welcome-text {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
@@ -226,6 +389,59 @@ export default {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* How it works */
|
||||
.how-it-works .steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.seo-copy .long-text {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.faq details {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
background: white;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cta-bottom {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.home-container {
|
||||
|
||||
65
frontend/src/views/Impressum.vue
Normal file
65
frontend/src/views/Impressum.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>Impressum</h1>
|
||||
<p class="back-home"><router-link to="/">Zur Startseite</router-link></p>
|
||||
<section>
|
||||
<h2>Diensteanbieter</h2>
|
||||
<p>
|
||||
Torsten Schulz<br/>
|
||||
Friedrich-Stampfer-Str. 21<br/>
|
||||
60437 Frankfurt<br/>
|
||||
Deutschland
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Kontakt</h2>
|
||||
<p>
|
||||
E-Mail: <a href="mailto:tsschulz@tsschulz.de">tsschulz@tsschulz.de</a>
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Vertretungsberechtigte Person</h2>
|
||||
<p>
|
||||
Torsten Schulz
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Umsatzsteuer-ID</h2>
|
||||
<p>
|
||||
Keine USt-IdNr. vorhanden (privat)
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Inhaltlich Verantwortlicher</h2>
|
||||
<p>
|
||||
Torsten Schulz (Anschrift wie oben)
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Impressum',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
section + section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.back-home {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,9 @@
|
||||
<input v-model="password" type="password" placeholder="Password" required />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
<div class="register-link">
|
||||
<p>Noch kein Konto? <router-link to="/register">Registrieren</router-link></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
<label><span>Geburtsdatum:</span> <input type="date" v-model="newBirthdate"></label>
|
||||
<label><span>Telefon-Nr.:</span> <input type="text" v-model="newPhone"></label>
|
||||
<label><span>Email-Adresse:</span> <input type="email" v-model="newEmail"></label>
|
||||
<label><span>Geschlecht:</span>
|
||||
<select v-model="newGender">
|
||||
<option value="unknown">Unbekannt</option>
|
||||
<option value="male">Männlich</option>
|
||||
<option value="female">Weiblich</option>
|
||||
<option value="diverse">Divers</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox-item"><span>Aktiv:</span> <input type="checkbox" v-model="newActive"></label>
|
||||
<label class="checkbox-item"><span>Pics in Internet erlaubt:</span> <input type="checkbox" v-model="newPicsInInternetAllowed"></label>
|
||||
<label class="checkbox-item"><span>Testmitgliedschaft:</span> <input type="checkbox" v-model="testMembership"></label>
|
||||
@@ -57,7 +65,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="member in members" :key="member.id">
|
||||
<tr v-if="member.active || showInactiveMembers" class="member-row" @click="editMember(member)">
|
||||
<tr v-if="member.active || showInactiveMembers" class="member-row" :class="{ 'row-inactive': !member.active }" @click="editMember(member)">
|
||||
<td>
|
||||
<img v-if="member.imageUrl" :src="member.imageUrl" alt="Mitgliedsbild"
|
||||
style="max-width: 50px; max-height: 50px;"
|
||||
@@ -65,7 +73,13 @@
|
||||
<span>{{ member.picsInInternetAllowed ? '✓' : '' }}</span>
|
||||
</td>
|
||||
<td>{{ member.testMembership ? '*' : '' }}</td>
|
||||
<td>{{ member.lastName }}, {{ member.firstName }}</td>
|
||||
<td>
|
||||
<span class="gender-symbol" :class="['gender-' + (member.gender || 'unknown'), { 'is-inactive': !member.active }]" :title="labelGender(member.gender)">{{ genderSymbol(member.gender) }}</span>
|
||||
<span class="gender-name" :class="['gender-' + (member.gender || 'unknown'), { 'is-inactive': !member.active }]">
|
||||
{{ member.lastName }}, {{ member.firstName }}
|
||||
<span v-if="!member.active && showInactiveMembers" class="inactive-badge">inaktiv</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ member.street }}, {{ member.city }}</td>
|
||||
<td>{{ getFormattedBirthdate(member.birthDate) }}</td>
|
||||
<td>{{ member.phone }}</td>
|
||||
@@ -123,9 +137,10 @@ export default {
|
||||
newLastname: '',
|
||||
newStreet: '',
|
||||
newCity: '',
|
||||
newBirthdate: '01.01.2010',
|
||||
newBirthdate: '',
|
||||
newPhone: '',
|
||||
newEmail: '',
|
||||
newGender: 'unknown',
|
||||
newActive: true,
|
||||
memberToEdit: null,
|
||||
memberImage: null,
|
||||
@@ -174,12 +189,13 @@ export default {
|
||||
this.newLastname = '';
|
||||
this.newStreet = '';
|
||||
this.newCity = '';
|
||||
this.newBirthdate = '01.01.2010';
|
||||
this.newBirthdate = '';
|
||||
this.newPhone = '';
|
||||
this.newEmail = '';
|
||||
this.newActive = true;
|
||||
this.newPicsInInternetAllowed = false;
|
||||
this.testMembership = true;
|
||||
this.newGender = 'unknown';
|
||||
this.memberImage = null;
|
||||
this.memberImagePreview = null;
|
||||
},
|
||||
@@ -203,6 +219,7 @@ export default {
|
||||
birthdate: this.newBirthdate,
|
||||
phone: this.newPhone,
|
||||
email: this.newEmail,
|
||||
gender: this.newGender,
|
||||
active: this.newActive,
|
||||
id: this.memberToEdit ? this.memberToEdit.id : null,
|
||||
testMembership: this.testMembership,
|
||||
@@ -246,8 +263,9 @@ export default {
|
||||
this.newCity = member.city;
|
||||
this.newPhone = member.phone;
|
||||
this.newEmail = member.email;
|
||||
this.newGender = member.gender || 'unknown';
|
||||
this.newActive = member.active;
|
||||
this.newBirthdate = date.toISOString().split('T')[0];
|
||||
this.newBirthdate = this.formatDateForInput(birthDate);
|
||||
this.testMembership = member.testMembership;
|
||||
this.newPicsInInternetAllowed = member.picsInInternetAllowed;
|
||||
try {
|
||||
@@ -260,6 +278,22 @@ export default {
|
||||
this.memberImagePreview = null;
|
||||
}
|
||||
},
|
||||
formatDateForInput(value) {
|
||||
if (!value || typeof value !== 'string') return '';
|
||||
const v = value.trim();
|
||||
// dd.mm.yyyy
|
||||
const m1 = v.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||||
if (m1) {
|
||||
const yyyy = m1[3];
|
||||
const mm = String(Number(m1[2])).padStart(2, '0');
|
||||
const dd = String(Number(m1[1])).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
// ISO/yyy-mm-dd
|
||||
const d = new Date(v);
|
||||
if (!isNaN(d.getTime())) return d.toISOString().split('T')[0];
|
||||
return '';
|
||||
},
|
||||
resetToNewMember() {
|
||||
this.memberToEdit = null;
|
||||
this.resetNewMember();
|
||||
@@ -324,6 +358,20 @@ export default {
|
||||
const date = new Date(birthDate);
|
||||
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
|
||||
},
|
||||
labelGender(g) {
|
||||
const v = (g || 'unknown');
|
||||
if (v === 'male') return 'Männlich';
|
||||
if (v === 'female') return 'Weiblich';
|
||||
if (v === 'diverse') return 'Divers';
|
||||
return 'Unbekannt';
|
||||
},
|
||||
genderSymbol(g) {
|
||||
const v = (g || 'unknown');
|
||||
if (v === 'male') return '♂';
|
||||
if (v === 'female') return '♀';
|
||||
if (v === 'diverse') return '⚧';
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -418,4 +466,27 @@ table td {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #ddd;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.gender-symbol, .gender-name { background: transparent; border: none; }
|
||||
|
||||
.gender-name.gender-male { color: #1a73e8; }
|
||||
.gender-name.gender-female { color: #d81b60; }
|
||||
.gender-name.gender-diverse { color: #6a1b9a; }
|
||||
.gender-name.gender-unknown { color: #444; }
|
||||
.gender-symbol.gender-male { color: #1a73e8; }
|
||||
.gender-symbol.gender-female { color: #d81b60; }
|
||||
.gender-symbol.gender-diverse { color: #6a1b9a; }
|
||||
.gender-symbol.gender-unknown { color: #444; }
|
||||
.gender-symbol { margin-right: .35rem; opacity: .9; font-size: 1.05em; display: inline-block; width: 1.1em; text-align: center; }
|
||||
.row-inactive { opacity: .6; }
|
||||
.is-inactive { text-decoration: line-through; }
|
||||
.inactive-badge { margin-left: .5rem; font-size: .85em; color: #666; text-transform: lowercase; }
|
||||
</style>
|
||||
|
||||
1335
frontend/src/views/OfficialTournaments.vue
Normal file
1335
frontend/src/views/OfficialTournaments.vue
Normal file
File diff suppressed because it is too large
Load Diff
508
frontend/src/views/PredefinedActivities.vue
Normal file
508
frontend/src/views/PredefinedActivities.vue
Normal file
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div class="predef-activities">
|
||||
<h2>Vordefinierte Aktivitäten</h2>
|
||||
<div class="grid">
|
||||
<div class="list">
|
||||
<div class="toolbar">
|
||||
<button @click="startCreate" class="btn-primary">Neu</button>
|
||||
<button @click="reload" class="btn-secondary">Neu laden</button>
|
||||
<div>
|
||||
<div>
|
||||
<button @click="deduplicate" class="btn-secondary">Doppelungen zusammenführen</button>
|
||||
</div
|
||||
<div class="merge-tools">
|
||||
<select v-model="mergeSourceId">
|
||||
<option disabled value="">Quelle wählen…</option>
|
||||
<option v-for="a in sortedActivities" :key="'s'+a.id" :value="a.id">{{ formatItem(a) }}</option>
|
||||
</select>
|
||||
<span>→</span>
|
||||
<select v-model="mergeTargetId">
|
||||
<option disabled value="">Ziel wählen…</option>
|
||||
<option v-for="a in sortedActivities" :key="'t'+a.id" :value="a.id">{{ formatItem(a) }}</option>
|
||||
</select>
|
||||
<button class="btn-secondary" :disabled="!canMerge" @click="mergeSelected">Zusammenführen</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="items">
|
||||
<li v-for="a in sortedActivities" :key="a.id" :class="{ active: selectedActivity && selectedActivity.id === a.id }" @click="select(a)">
|
||||
<div class="title">
|
||||
<strong>{{ a.code ? '[' + a.code + '] ' : '' }}{{ a.name }}</strong>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span v-if="a.duration">{{ a.duration }} min</span>
|
||||
<span v-if="a.durationText"> ({{ a.durationText }})</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="detail" v-if="editModel">
|
||||
<h3>{{ editModel.id ? 'Aktivität bearbeiten' : 'Neue Aktivität' }}</h3>
|
||||
<form @submit.prevent="save">
|
||||
<label>Name
|
||||
<input type="text" v-model="editModel.name" required />
|
||||
</label>
|
||||
<label>Kürzel
|
||||
<input type="text" v-model="editModel.code" />
|
||||
</label>
|
||||
<label>Dauer (Minuten)
|
||||
<input type="number" v-model.number="editModel.duration" min="0" />
|
||||
</label>
|
||||
<label>Dauer (Text)
|
||||
<input type="text" v-model="editModel.durationText" placeholder="z.B. 2x7" />
|
||||
</label>
|
||||
<label>Beschreibung
|
||||
<textarea v-model="editModel.description" rows="4" />
|
||||
</label>
|
||||
<div class="image-section">
|
||||
<h4>Bild hinzufügen</h4>
|
||||
<p class="image-help">Du kannst entweder einen Link zu einem Bild eingeben, ein Bild hochladen oder eine Übungszeichnung erstellen:</p>
|
||||
|
||||
<label>Bild-Link (optional)
|
||||
<input type="text" v-model="editModel.imageLink" placeholder="z.B. https://example.com/bild.jpg oder /api/predefined-activities/:id/image/:imageId" />
|
||||
</label>
|
||||
|
||||
<div class="upload-section">
|
||||
<label>Oder Bild hochladen:
|
||||
<input type="file" accept="image/*" @change="onFileChange" />
|
||||
</label>
|
||||
<button class="btn-secondary" :disabled="!selectedFile" @click="uploadImage">
|
||||
{{ editModel.id ? 'Hochladen' : 'Nach Speichern hochladen' }}
|
||||
</button>
|
||||
<p v-if="!editModel.id" class="upload-note">
|
||||
Hinweis: Das Bild wird erst nach dem Speichern der Aktivität hochgeladen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Zeichen-Tool -->
|
||||
<div class="drawing-section">
|
||||
<h5>Übungszeichnung erstellen</h5>
|
||||
<CourtDrawingTool
|
||||
:activity-id="editModel.id"
|
||||
:drawing-data="editModel.drawingData"
|
||||
:allow-image-upload="false"
|
||||
@update-fields="onUpdateFields"
|
||||
@update-drawing-data="onUpdateDrawingData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="image-list" v-if="images && images.length">
|
||||
<h5>Hochgeladene Bilder:</h5>
|
||||
<div class="image-grid">
|
||||
<div v-for="img in images" :key="img.id" class="image-item">
|
||||
<img :src="imageUrl(img)" alt="Predefined Activity Image" />
|
||||
<button class="btn-small btn-danger" @click="deleteImage(img.id)">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
<button type="button" class="btn-secondary" @click="cancel">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
import CourtDrawingTool from '../components/CourtDrawingTool.vue';
|
||||
|
||||
export default {
|
||||
name: 'PredefinedActivities',
|
||||
components: {
|
||||
CourtDrawingTool
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activities: [],
|
||||
selectedActivity: null,
|
||||
editModel: null,
|
||||
images: [],
|
||||
selectedFile: null,
|
||||
selectedDrawingData: null,
|
||||
mergeSourceId: '',
|
||||
mergeTargetId: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
sortedActivities() {
|
||||
return [...(this.activities || [])].sort((a, b) => {
|
||||
const ac = (a.code || '').toLocaleLowerCase('de-DE');
|
||||
const bc = (b.code || '').toLocaleLowerCase('de-DE');
|
||||
const aEmpty = ac === '';
|
||||
const bEmpty = bc === '';
|
||||
if (aEmpty !== bEmpty) return aEmpty ? 1 : -1; // leere Codes nach hinten
|
||||
if (ac < bc) return -1; if (ac > bc) return 1;
|
||||
const an = (a.name || '').toLocaleLowerCase('de-DE');
|
||||
const bn = (b.name || '').toLocaleLowerCase('de-DE');
|
||||
if (an < bn) return -1; if (an > bn) return 1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
canMerge() {
|
||||
return this.mergeSourceId && this.mergeTargetId && String(this.mergeSourceId) !== String(this.mergeTargetId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
parseDrawingData(value) {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') return value;
|
||||
try { return JSON.parse(value); } catch (e) { return null; }
|
||||
},
|
||||
normalizeActivity(activity, images) {
|
||||
const drawingData = this.parseDrawingData(activity && activity.drawingData);
|
||||
return { ...(activity || {}), drawingData };
|
||||
},
|
||||
async reload() {
|
||||
const r = await apiClient.get('/predefined-activities');
|
||||
this.activities = r.data || [];
|
||||
},
|
||||
async select(a) {
|
||||
this.selectedActivity = a;
|
||||
const r = await apiClient.get(`/predefined-activities/${a.id}`);
|
||||
const { images, ...activity } = r.data;
|
||||
this.images = images || [];
|
||||
// Server-Daten normalisieren und ggf. image-drawingData fallbacken
|
||||
let model = this.normalizeActivity(activity, images);
|
||||
if (!model.drawingData && images && images.length > 0 && images[0].drawingData) {
|
||||
model.drawingData = this.parseDrawingData(images[0].drawingData);
|
||||
}
|
||||
this.editModel = model;
|
||||
},
|
||||
async reloadImages() {
|
||||
if (this.editModel && this.editModel.id) {
|
||||
try {
|
||||
const r = await apiClient.get(`/predefined-activities/${this.editModel.id}`);
|
||||
const { images } = r.data;
|
||||
this.images = images || [];
|
||||
console.log('Images reloaded:', this.images);
|
||||
} catch (error) {
|
||||
console.error('Error reloading images:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
formatItem(a) {
|
||||
return `${a.code ? '[' + a.code + '] ' : ''}${a.name}`;
|
||||
},
|
||||
async mergeSelected() {
|
||||
if (!this.canMerge) return;
|
||||
const src = this.mergeSourceId; const tgt = this.mergeTargetId;
|
||||
if (!confirm(`Eintrag #${src} in #${tgt} zusammenführen?\nAlle Verknüpfungen werden auf das Ziel umgebogen, die Quelle wird gelöscht.`)) return;
|
||||
await apiClient.post('/predefined-activities/merge', { sourceId: src, targetId: tgt });
|
||||
this.mergeSourceId = '';
|
||||
this.mergeTargetId = '';
|
||||
await this.reload();
|
||||
},
|
||||
startCreate() {
|
||||
this.selectedActivity = null;
|
||||
this.images = [];
|
||||
this.editModel = {
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
duration: null,
|
||||
durationText: '',
|
||||
imageLink: '',
|
||||
drawingData: '',
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
this.editModel = null;
|
||||
this.selectedActivity = null;
|
||||
this.images = [];
|
||||
},
|
||||
async save() {
|
||||
if (!this.editModel) return;
|
||||
|
||||
console.log('Save: selectedFile =', this.selectedFile);
|
||||
|
||||
if (this.editModel.id) {
|
||||
const { id, ...payload } = this.editModel;
|
||||
if (payload.drawingData && typeof payload.drawingData === 'object') {
|
||||
payload.drawingData = payload.drawingData;
|
||||
}
|
||||
const r = await apiClient.put(`/predefined-activities/${id}`, payload);
|
||||
this.editModel = this.normalizeActivity(r.data);
|
||||
} else {
|
||||
const r = await apiClient.post('/predefined-activities', this.editModel);
|
||||
this.editModel = this.normalizeActivity(r.data);
|
||||
}
|
||||
|
||||
// Nach dem Speichern (sowohl CREATE als auch UPDATE): Bild hochladen falls vorhanden
|
||||
if (this.selectedFile) {
|
||||
console.log('Uploading image after save...');
|
||||
await this.uploadImage();
|
||||
} else {
|
||||
console.log('No selectedFile to upload');
|
||||
}
|
||||
|
||||
await this.reload();
|
||||
},
|
||||
onFileChange(e) {
|
||||
this.selectedFile = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
||||
},
|
||||
imageUrl(img) {
|
||||
return `http://localhost:3000/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
|
||||
},
|
||||
async uploadImage() {
|
||||
if (!this.editModel || !this.editModel.id || !this.selectedFile) {
|
||||
console.log('Upload skipped: editModel=', this.editModel, 'selectedFile=', this.selectedFile);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting image upload...');
|
||||
console.log('editModel:', this.editModel);
|
||||
console.log('selectedActivity:', this.selectedActivity);
|
||||
console.log('Activity ID (editModel.id):', this.editModel.id);
|
||||
console.log('Activity ID (selectedActivity.id):', this.selectedActivity?.id);
|
||||
console.log('File:', this.selectedFile);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('image', this.selectedFile);
|
||||
|
||||
// Füge Zeichnungsdaten hinzu, falls vorhanden
|
||||
if (this.selectedDrawingData) {
|
||||
fd.append('drawingData', JSON.stringify(this.selectedDrawingData));
|
||||
console.log('Added drawingData to FormData:', this.selectedDrawingData);
|
||||
}
|
||||
|
||||
// Verwende PUT für Updates, POST für neue Activities
|
||||
const isUpdate = this.selectedActivity && this.selectedActivity.id === this.editModel.id;
|
||||
const method = isUpdate ? 'put' : 'post';
|
||||
|
||||
console.log('Using method:', method);
|
||||
|
||||
try {
|
||||
const response = await apiClient[method](`/predefined-activities/${this.editModel.id}/image`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
console.log('Upload successful:', response);
|
||||
|
||||
// Nach Upload Details neu laden
|
||||
await this.select(this.editModel);
|
||||
this.selectedFile = null;
|
||||
|
||||
// Bildliste explizit aktualisieren
|
||||
await this.reloadImages();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async deleteImage(imageId) {
|
||||
if (!this.editModel || !this.editModel.id) return;
|
||||
if (!confirm('Bild wirklich löschen?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/predefined-activities/${this.editModel.id}/image/${imageId}`);
|
||||
// Nach Löschen Details neu laden
|
||||
await this.select(this.editModel);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Bildes:', error);
|
||||
alert('Fehler beim Löschen des Bildes');
|
||||
}
|
||||
},
|
||||
async deduplicate() {
|
||||
if (!confirm('Alle Aktivitäten mit identischem Namen werden zusammengeführt. Fortfahren?')) return;
|
||||
await apiClient.post('/predefined-activities/deduplicate', {});
|
||||
await this.reload();
|
||||
},
|
||||
onDrawingSave(drawingData) {
|
||||
if (this.editModel) {
|
||||
this.editModel.drawingData = drawingData;
|
||||
// Automatisch als Bild-Link setzen, wenn es eine Zeichnung gibt
|
||||
if (drawingData && !this.editModel.imageLink) {
|
||||
this.editModel.imageLink = drawingData;
|
||||
}
|
||||
}
|
||||
// Nicht automatisch speichern, nur wenn User explizit "Speichern" klickt
|
||||
},
|
||||
onUpdateFields(fields) {
|
||||
if (this.editModel) {
|
||||
this.editModel.code = fields.code;
|
||||
this.editModel.name = fields.name;
|
||||
this.editModel.description = fields.description;
|
||||
}
|
||||
},
|
||||
onUpdateDrawingData(data) {
|
||||
if (this.editModel) {
|
||||
this.editModel.drawingData = data;
|
||||
}
|
||||
},
|
||||
|
||||
async onDrawingImageUpload(file, drawingData) {
|
||||
console.log('onDrawingImageUpload called with file:', file);
|
||||
console.log('onDrawingImageUpload called with drawingData:', drawingData);
|
||||
console.log('File type:', file?.type);
|
||||
console.log('File size:', file?.size);
|
||||
console.log('File name:', file?.name);
|
||||
|
||||
// Setze das File und die Zeichnungsdaten für den Upload
|
||||
this.selectedFile = file;
|
||||
this.selectedDrawingData = drawingData;
|
||||
console.log('selectedFile set to:', this.selectedFile);
|
||||
console.log('selectedDrawingData set to:', this.selectedDrawingData);
|
||||
|
||||
// Upload wird erst beim Speichern durchgeführt
|
||||
console.log('File and drawing data ready for upload when saving');
|
||||
},
|
||||
|
||||
async onImageUploaded() {
|
||||
console.log('Image uploaded successfully, refreshing image list...');
|
||||
// Bildliste aktualisieren
|
||||
if (this.editModel && this.editModel.id) {
|
||||
await this.select(this.editModel);
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.reload();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.predef-activities {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 170px);
|
||||
}
|
||||
.list {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.merge-tools { display: inline-flex; align-items: center; gap: .35rem; margin-left: auto; }
|
||||
select { max-width: 220px; }
|
||||
.items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.items li {
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.items li:hover { background: var(--primary-light);
|
||||
}
|
||||
.items li.active { background: var(--primary-light); color: var(--primary-color); }
|
||||
.detail {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.75rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
max-height: calc(100vh - 170px);
|
||||
overflow: auto;
|
||||
}
|
||||
label { display: block; margin-bottom: 0.5rem; }
|
||||
input[type="text"], input[type="number"], textarea { width: 100%; }
|
||||
.actions { margin-top: 0.75rem; display: flex; gap: 0.5rem; }
|
||||
.image-section {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.image-help {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: var(--border-radius-small);
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.upload-note {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
position: relative;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: var(--border-radius-small);
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.image-item img {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-item button {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-small);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.drawing-section {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.drawing-section h5 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<input v-model="password" type="password" placeholder="Password" required />
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
<div class="login-link">
|
||||
<p>Bereits ein Konto? <router-link to="/login">Zum Login</router-link></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,7 +26,7 @@
|
||||
methods: {
|
||||
async register() {
|
||||
try {
|
||||
await axios.post('/api/auth/register', { email: this.email, password: this.password });
|
||||
await axios.post(`${import.meta.env.VITE_BACKEND}/api/auth/register`, { email: this.email, password: this.password });
|
||||
alert('Registration successful! Please check your email to activate your account.');
|
||||
} catch (error) {
|
||||
alert('Registrierung fehlgeschlagen');
|
||||
|
||||
@@ -107,11 +107,70 @@ export default {
|
||||
alert('Fehler beim Importieren der CSV-Datei');
|
||||
}
|
||||
},
|
||||
// Sortierfunktion für Ligen
|
||||
sortLeagues(leagues) {
|
||||
// Ligen-Priorität
|
||||
const leagueOrder = [
|
||||
'1. Bundesliga',
|
||||
'2. Bundesliga',
|
||||
'3. Bundesliga',
|
||||
'Regionalliga',
|
||||
'Oberliga',
|
||||
'Verbandsliga (Hessen)',
|
||||
'Bezirksoberliga',
|
||||
'Bezirksliga',
|
||||
'Bezirksklasse',
|
||||
'Kreisliga',
|
||||
'1. Kreisklasse',
|
||||
'2. Kreisklasse',
|
||||
'3. Kreisklasse',
|
||||
];
|
||||
|
||||
// Hilfsfunktionen
|
||||
function getLeagueIndex(name) {
|
||||
for (let i = 0; i < leagueOrder.length; i++) {
|
||||
if (name.includes(leagueOrder[i])) return i;
|
||||
}
|
||||
return leagueOrder.length;
|
||||
}
|
||||
function parseYouth(name) {
|
||||
// Gibt {type: 'J'|'M'|'Jugend'|null, age: Zahl|null} zurück
|
||||
const m = name.match(/([JM])(\d{1,2})/i);
|
||||
if (m) return { type: m[1].toUpperCase(), age: parseInt(m[2]) };
|
||||
if (/jugend/i.test(name)) return { type: 'Jugend', age: null };
|
||||
return { type: null, age: null };
|
||||
}
|
||||
|
||||
// Sortierlogik
|
||||
return leagues.slice().sort((a, b) => {
|
||||
const ya = parseYouth(a.name);
|
||||
const yb = parseYouth(b.name);
|
||||
// Erwachsene zuerst
|
||||
if (!ya.type && yb.type) return -1;
|
||||
if (ya.type && !yb.type) return 1;
|
||||
if (!ya.type && !yb.type) {
|
||||
// Beide Erwachsene: nach Liga
|
||||
return getLeagueIndex(a.name) - getLeagueIndex(b.name);
|
||||
}
|
||||
// Beide Jugend: erst nach Alter aufsteigend (älteste unten), dann J vor M vor "Jugend", dann Liga
|
||||
// "Jugend" ohne Zahl ist die jüngste Jugendklasse
|
||||
if (ya.age !== yb.age) {
|
||||
if (ya.age === null) return -1;
|
||||
if (yb.age === null) return 1;
|
||||
return ya.age - yb.age;
|
||||
}
|
||||
// Reihenfolge: J < M < Jugend
|
||||
const typeOrder = { 'J': 0, 'M': 1, 'Jugend': 2 };
|
||||
if (ya.type !== yb.type) return (typeOrder[ya.type] || 2) - (typeOrder[yb.type] || 2);
|
||||
return getLeagueIndex(a.name) - getLeagueIndex(b.name);
|
||||
});
|
||||
},
|
||||
|
||||
async loadLeagues() {
|
||||
try {
|
||||
const clubId = this.currentClub;
|
||||
const response = await apiClient.get(`/matches/leagues/current/${clubId}`);
|
||||
this.leagues = response.data;
|
||||
this.leagues = this.sortLeagues(response.data);
|
||||
} catch (error) {
|
||||
alert('Fehler beim Laden der Ligen');
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<select v-model="selectedDate">
|
||||
<option value="new">Neues Turnier</option>
|
||||
<option v-for="date in dates" :key="date.id" :value="date.id">
|
||||
{{ new Date(date.date).toLocaleDateString('de-DE', {
|
||||
{{ date.tournamentName || (date.date ? new Date(date.date).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}) }}
|
||||
}) : 'Unbekanntes Datum') }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="selectedDate === 'new'" class="new-tournament">
|
||||
@@ -24,34 +24,44 @@
|
||||
<span>Spielen in Gruppen</span>
|
||||
</label>
|
||||
<section class="participants">
|
||||
<h4>Teilnehmer</h4>
|
||||
<ul>
|
||||
<li v-for="participant in participants" :key="participant.id">
|
||||
{{ participant.member.firstName }}
|
||||
{{ participant.member.lastName }}
|
||||
<template v-if="isGroupTournament">
|
||||
<label class="inline-label">
|
||||
Gruppe:
|
||||
<select v-model.number="participant.groupNumber">
|
||||
<option :value="null">–</option>
|
||||
<option v-for="group in groups" :key="group.groupId" :value="group.groupNumber">
|
||||
Gruppe {{ group.groupNumber }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<button @click="removeParticipant(participant)" style="margin-left:0.5rem" class="trash-btn">
|
||||
🗑️
|
||||
<div class="participants-header" @click="toggleParticipants">
|
||||
<h4>Teilnehmer</h4>
|
||||
<span class="collapse-icon" :class="{ 'expanded': showParticipants }">▼</span>
|
||||
</div>
|
||||
<div v-show="showParticipants" class="participants-content">
|
||||
<ul>
|
||||
<li v-for="participant in participants" :key="participant.id">
|
||||
{{ participant.member?.firstName || 'Unbekannt' }}
|
||||
{{ participant.member?.lastName || '' }}
|
||||
<template v-if="isGroupTournament">
|
||||
<label class="inline-label">
|
||||
Gruppe:
|
||||
<select v-model.number="participant.groupNumber" @change="updateParticipantGroup(participant, $event)">
|
||||
<option :value="null">–</option>
|
||||
<option v-for="group in groups" :key="group.groupId" :value="group.groupNumber">
|
||||
Gruppe {{ group.groupNumber }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<button @click="removeParticipant(participant)" style="margin-left:0.5rem" class="trash-btn">
|
||||
🗑️
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="add-participant">
|
||||
<select v-model="selectedMember">
|
||||
<option v-for="member in clubMembers" :key="member.id" :value="member.id">
|
||||
{{ member.firstName }}
|
||||
{{ member.lastName }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="addParticipant">Hinzufügen</button>
|
||||
<button v-if="hasTrainingToday" @click="loadParticipantsFromTraining" class="training-btn">
|
||||
📅 Aus Trainingstag laden
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<select v-model="selectedMember">
|
||||
<option v-for="member in clubMembers" :key="member.id" :value="member.id">
|
||||
{{ member.firstName }}
|
||||
{{ member.lastName }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="addParticipant">Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="isGroupTournament" class="group-controls">
|
||||
<label>
|
||||
@@ -78,15 +88,21 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Index</th>
|
||||
<th>Platz</th>
|
||||
<th>Spieler</th>
|
||||
<th>Punkte</th>
|
||||
<th>Satz</th>
|
||||
<th>Diff</th>
|
||||
<th v-for="(opponent, idx) in groupRankings[group.groupId]" :key="`opp-${opponent.id}`">
|
||||
G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}
|
||||
</th>
|
||||
<th>Live-Platz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pl in groupRankings[group.groupId]" :key="pl.id">
|
||||
<tr v-for="(pl, idx) in groupRankings[group.groupId]" :key="pl.id">
|
||||
<td><strong>G{{ String.fromCharCode(96 + group.groupNumber) }}{{ idx + 1 }}</strong></td>
|
||||
<td>{{ pl.position }}.</td>
|
||||
<td>{{ pl.name }}</td>
|
||||
<td>{{ pl.points }}</td>
|
||||
@@ -94,6 +110,18 @@
|
||||
<td>
|
||||
{{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}
|
||||
</td>
|
||||
<td v-for="(opponent, oppIdx) in groupRankings[group.groupId]"
|
||||
:key="`match-${pl.id}-${opponent.id}`"
|
||||
:class="['match-cell', { 'clickable': idx !== oppIdx }]"
|
||||
@click="idx !== oppIdx ? highlightMatch(pl.id, opponent.id, group.groupId) : null">
|
||||
<span v-if="idx === oppIdx" class="diagonal"></span>
|
||||
<span v-else-if="getMatchLiveResult(pl.id, opponent.id, group.groupId)"
|
||||
:class="getMatchCellClasses(pl.id, opponent.id, group.groupId)">
|
||||
{{ getMatchDisplayText(pl.id, opponent.id, group.groupId) }}
|
||||
</span>
|
||||
<span v-else class="no-match">-</span>
|
||||
</td>
|
||||
<td>{{ getLivePosition(pl.id, group.groupId) }}.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -122,7 +150,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in groupMatches" :key="m.id">
|
||||
<tr v-for="m in groupMatches" :key="m.id" :data-match-id="m.id">
|
||||
<td>{{ m.groupRound }}</td>
|
||||
<td>{{ m.groupNumber }}</td>
|
||||
<td>
|
||||
@@ -301,6 +329,8 @@ export default {
|
||||
groups: [],
|
||||
matches: [],
|
||||
showKnockout: false,
|
||||
showParticipants: false, // Kollaps-Status für Teilnehmerliste
|
||||
hasTrainingToday: false, // Gibt es einen Trainingstag heute?
|
||||
editingResult: {
|
||||
matchId: null, // aktuell bearbeitetes Match
|
||||
set: null, // aktuell bearbeitete Satz‑Nummer
|
||||
@@ -348,8 +378,13 @@ export default {
|
||||
const e1 = arr.find(x => x.id === m.player1.id);
|
||||
const e2 = arr.find(x => x.id === m.player2.id);
|
||||
if (!e1 || !e2) return;
|
||||
if (s1 > s2) e1.points += 2;
|
||||
else if (s2 > s1) e2.points += 2;
|
||||
if (s1 > s2) {
|
||||
e1.points += 1; // Sieger bekommt +1
|
||||
e2.points -= 1; // Verlierer bekommt -1
|
||||
} else if (s2 > s1) {
|
||||
e2.points += 1; // Sieger bekommt +1
|
||||
e1.points -= 1; // Verlierer bekommt -1
|
||||
}
|
||||
e1.setsWon += s1; e1.setsLost += s2;
|
||||
e2.setsWon += s2; e2.setsLost += s1;
|
||||
});
|
||||
@@ -369,6 +404,15 @@ export default {
|
||||
return rankings;
|
||||
},
|
||||
|
||||
// Mapping von groupId zu groupNumber für die Teilnehmer-Auswahl
|
||||
groupIdToNumberMap() {
|
||||
const map = {};
|
||||
this.groups.forEach(g => {
|
||||
map[g.groupId] = g.groupNumber;
|
||||
});
|
||||
return map;
|
||||
},
|
||||
|
||||
rankingList() {
|
||||
const finalMatch = this.knockoutMatches.find(
|
||||
m => m.round.toLowerCase() === 'finale'
|
||||
@@ -435,6 +479,23 @@ export default {
|
||||
);
|
||||
this.clubMembers = m.data;
|
||||
},
|
||||
mounted() {
|
||||
// Lade Turniere beim Start
|
||||
this.loadTournaments();
|
||||
|
||||
// Event-Listener für das Entfernen des Highlights
|
||||
document.addEventListener('click', (e) => {
|
||||
// Entferne Highlight nur wenn nicht auf eine Matrix-Zelle geklickt wird
|
||||
if (!e.target.closest('.match-cell')) {
|
||||
this.clearHighlight();
|
||||
}
|
||||
});
|
||||
|
||||
// Event-Listener für Eingabefelder
|
||||
document.addEventListener('input', () => {
|
||||
this.clearHighlight();
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
normalizeResultInput(raw) {
|
||||
const s = raw.trim();
|
||||
@@ -493,11 +554,27 @@ export default {
|
||||
m[g.groupId] = g.groupNumber;
|
||||
return m;
|
||||
}, {});
|
||||
this.matches = mRes.data.map(m => ({
|
||||
...m,
|
||||
groupNumber: grpMap[m.groupId] || 0,
|
||||
resultInput: ''
|
||||
}));
|
||||
|
||||
this.matches = mRes.data.map(m => {
|
||||
// Bestimme groupId basierend auf den Spielern, da die Matches groupId: null haben
|
||||
const player1GroupId = m.player1?.groupId;
|
||||
const player2GroupId = m.player2?.groupId;
|
||||
const matchGroupId = player1GroupId || player2GroupId;
|
||||
|
||||
return {
|
||||
...m,
|
||||
groupId: matchGroupId, // Überschreibe null mit der korrekten groupId
|
||||
groupNumber: grpMap[matchGroupId] || 0,
|
||||
resultInput: ''
|
||||
};
|
||||
});
|
||||
|
||||
// Initialisiere groupNumber für Teilnehmer basierend auf groupId
|
||||
this.initializeParticipantGroupNumbers();
|
||||
|
||||
// Setze Kollaps-Status: ausgeklappt wenn keine Spiele, eingeklappt wenn Spiele vorhanden
|
||||
this.showParticipants = this.matches.length === 0;
|
||||
|
||||
this.showKnockout = this.matches.some(m => m.round !== 'group');
|
||||
},
|
||||
|
||||
@@ -505,15 +582,65 @@ export default {
|
||||
return p.member.firstName + ' ' + p.member.lastName;
|
||||
},
|
||||
|
||||
async loadTournaments() {
|
||||
try {
|
||||
const d = await apiClient.get(`/tournament/${this.currentClub}`);
|
||||
this.dates = d.data;
|
||||
console.log('Loaded tournaments:', this.dates);
|
||||
|
||||
// Prüfe, ob es einen Trainingstag heute gibt
|
||||
await this.checkTrainingToday();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Turniere:', error);
|
||||
this.dates = [];
|
||||
}
|
||||
},
|
||||
|
||||
async checkTrainingToday() {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD Format
|
||||
const response = await apiClient.get(`/diary/${this.currentClub}`);
|
||||
|
||||
console.log('Training check response:', response.data);
|
||||
|
||||
// Die API gibt alle Trainingstage zurück, filtere nach heute
|
||||
const trainingData = response.data;
|
||||
if (Array.isArray(trainingData)) {
|
||||
this.hasTrainingToday = trainingData.some(training => training.date === today);
|
||||
} else {
|
||||
this.hasTrainingToday = false;
|
||||
}
|
||||
console.log('Training today:', this.hasTrainingToday);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Prüfen des Trainingstags:', error);
|
||||
this.hasTrainingToday = false;
|
||||
}
|
||||
},
|
||||
|
||||
async createTournament() {
|
||||
const r = await apiClient.post('/tournament', {
|
||||
clubId: this.currentClub,
|
||||
tournamentName: this.newDate,
|
||||
date: this.newDate
|
||||
});
|
||||
this.dates = r.data;
|
||||
this.selectedDate = this.dates[this.dates.length - 1].id;
|
||||
this.newDate = '';
|
||||
try {
|
||||
const r = await apiClient.post('/tournament', {
|
||||
clubId: this.currentClub,
|
||||
tournamentName: this.newDate,
|
||||
date: this.newDate
|
||||
});
|
||||
|
||||
console.log('Tournament created, response:', r.data);
|
||||
|
||||
// Speichere die ID des neuen Turniers
|
||||
const newTournamentId = r.data.id;
|
||||
|
||||
// Lade die Turniere neu
|
||||
await this.loadTournaments();
|
||||
|
||||
// Setze das neue Turnier als ausgewählt
|
||||
this.selectedDate = newTournamentId;
|
||||
|
||||
this.newDate = '';
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen des Turniers:', error);
|
||||
alert('Fehler beim Erstellen des Turniers: ' + (error.response?.data?.error || error.message));
|
||||
}
|
||||
},
|
||||
|
||||
async addParticipant() {
|
||||
@@ -785,6 +912,365 @@ export default {
|
||||
} catch (err) {
|
||||
alert('Fehler beim Zurücksetzen der K.o.-Runde');
|
||||
}
|
||||
},
|
||||
|
||||
getMatchResult(player1Id, player2Id, groupId) {
|
||||
const match = this.matches.find(m =>
|
||||
m.round === 'group' &&
|
||||
m.groupId === groupId &&
|
||||
((m.player1.id === player1Id && m.player2.id === player2Id) ||
|
||||
(m.player1.id === player2Id && m.player2.id === player1Id)) &&
|
||||
m.isFinished
|
||||
);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
// Bestimme, wer gewonnen hat
|
||||
const [sets1, sets2] = match.result.split(':').map(n => +n);
|
||||
const player1Won = sets1 > sets2;
|
||||
|
||||
// Gib das Ergebnis in der Sicht des ersten Spielers zurück
|
||||
if (match.player1.id === player1Id) {
|
||||
return player1Won ? 'W' : 'L';
|
||||
} else {
|
||||
return player1Won ? 'L' : 'W';
|
||||
}
|
||||
},
|
||||
|
||||
getMatchLiveResult(player1Id, player2Id, groupId) {
|
||||
const match = this.matches.find(m =>
|
||||
m.round === 'group' &&
|
||||
m.groupId === groupId &&
|
||||
((m.player1.id === player1Id && m.player2.id === player2Id) ||
|
||||
(m.player1.id === player2Id && m.player2.id === player1Id))
|
||||
);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
// Berechne aktuelle Sätze aus tournamentResults
|
||||
let sets1 = 0, sets2 = 0;
|
||||
if (match.tournamentResults && match.tournamentResults.length > 0) {
|
||||
match.tournamentResults.forEach(result => {
|
||||
if (result.pointsPlayer1 > result.pointsPlayer2) {
|
||||
sets1++;
|
||||
} else if (result.pointsPlayer2 > result.pointsPlayer1) {
|
||||
sets2++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bestimme die Anzeige basierend auf der Spieler-Reihenfolge
|
||||
if (match.player1.id === player1Id) {
|
||||
return {
|
||||
sets1: sets1,
|
||||
sets2: sets2,
|
||||
isFinished: match.isFinished,
|
||||
player1Won: sets1 > sets2,
|
||||
player2Won: sets2 > sets1,
|
||||
isTie: sets1 === sets2
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
sets1: sets2,
|
||||
sets2: sets1,
|
||||
isFinished: match.isFinished,
|
||||
player1Won: sets2 > sets1,
|
||||
player2Won: sets1 > sets2,
|
||||
isTie: sets1 === sets2
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
highlightMatch(player1Id, player2Id, groupId) {
|
||||
console.log('highlightMatch called:', { player1Id, player2Id, groupId });
|
||||
|
||||
// Finde das entsprechende Match (auch unbeendete)
|
||||
const match = this.matches.find(m =>
|
||||
m.round === 'group' &&
|
||||
m.groupId === groupId &&
|
||||
((m.player1.id === player1Id && m.player2.id === player2Id) ||
|
||||
(m.player1.id === player2Id && m.player2.id === player1Id))
|
||||
);
|
||||
|
||||
console.log('Found match:', match);
|
||||
|
||||
if (!match) {
|
||||
console.log('No match found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Setze Highlight-Klasse
|
||||
this.$nextTick(() => {
|
||||
const matchElement = document.querySelector(`tr[data-match-id="${match.id}"]`);
|
||||
console.log('Match element:', matchElement);
|
||||
|
||||
if (matchElement) {
|
||||
// Entferne vorherige Highlights
|
||||
document.querySelectorAll('.match-highlight').forEach(el => {
|
||||
el.classList.remove('match-highlight');
|
||||
});
|
||||
|
||||
// Füge Highlight hinzu
|
||||
matchElement.classList.add('match-highlight');
|
||||
|
||||
// Scrolle zum Element
|
||||
matchElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
} else {
|
||||
console.log('Match element not found in DOM');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clearHighlight() {
|
||||
// Entferne alle Highlights
|
||||
document.querySelectorAll('.match-highlight').forEach(el => {
|
||||
el.classList.remove('match-highlight');
|
||||
});
|
||||
},
|
||||
|
||||
async updateParticipantGroup(participant, event) {
|
||||
const groupNumber = parseInt(event.target.value);
|
||||
|
||||
// Aktualisiere lokal
|
||||
participant.groupNumber = groupNumber;
|
||||
|
||||
// Bereite alle Teilnehmer-Zuordnungen vor
|
||||
const assignments = this.participants.map(p => ({
|
||||
participantId: p.id,
|
||||
groupNumber: p.groupNumber || null
|
||||
}));
|
||||
|
||||
// Sende an Backend
|
||||
try {
|
||||
await apiClient.post('/tournament/groups/manual', {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate,
|
||||
assignments: assignments,
|
||||
numberOfGroups: this.numberOfGroups,
|
||||
maxGroupSize: this.maxGroupSize
|
||||
});
|
||||
|
||||
// Lade Daten neu, um die aktualisierten Gruppen zu erhalten
|
||||
await this.loadTournamentData();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren der Gruppe:', error);
|
||||
// Bei Fehler: Lade Daten neu
|
||||
await this.loadTournamentData();
|
||||
}
|
||||
},
|
||||
|
||||
// Initialisiere groupNumber basierend auf groupId
|
||||
initializeParticipantGroupNumbers() {
|
||||
this.participants.forEach(participant => {
|
||||
if (participant.groupId) {
|
||||
const group = this.groups.find(g => g.groupId === participant.groupId);
|
||||
if (group) {
|
||||
participant.groupNumber = group.groupNumber;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
toggleParticipants() {
|
||||
this.showParticipants = !this.showParticipants;
|
||||
},
|
||||
|
||||
async loadParticipantsFromTraining() {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD Format
|
||||
const response = await apiClient.get(`/diary/${this.currentClub}`);
|
||||
|
||||
console.log('Training response:', response.data);
|
||||
console.log('Looking for date:', today);
|
||||
|
||||
// Die API gibt alle Trainingstage zurück, filtere nach heute
|
||||
const trainingData = response.data;
|
||||
|
||||
if (Array.isArray(trainingData)) {
|
||||
console.log('Available training dates:', trainingData.map(t => t.date));
|
||||
|
||||
// Finde den Trainingstag für heute
|
||||
const todayTraining = trainingData.find(training => training.date === today);
|
||||
console.log('Today training found:', todayTraining);
|
||||
|
||||
if (todayTraining) {
|
||||
// Lade die Teilnehmer für diesen Trainingstag über die Participant-API
|
||||
const participantsResponse = await apiClient.get(`/participants/${todayTraining.id}`);
|
||||
console.log('Participants response:', participantsResponse.data);
|
||||
|
||||
const participants = participantsResponse.data;
|
||||
|
||||
if (participants && participants.length > 0) {
|
||||
// Lade die Member-Details für jeden Teilnehmer
|
||||
const trainingParticipants = [];
|
||||
for (const participant of participants) {
|
||||
try {
|
||||
// Lade Member-Details über die Member-API
|
||||
const memberResponse = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
|
||||
const member = memberResponse.data.find(m => m.id === participant.memberId);
|
||||
|
||||
if (member) {
|
||||
trainingParticipants.push({
|
||||
clubMemberId: participant.memberId,
|
||||
member: member
|
||||
});
|
||||
}
|
||||
} catch (memberError) {
|
||||
console.error('Fehler beim Laden der Member-Details:', memberError);
|
||||
}
|
||||
}
|
||||
|
||||
if (trainingParticipants.length > 0) {
|
||||
// Füge alle Trainingsteilnehmer zum Turnier hinzu
|
||||
for (const participant of trainingParticipants) {
|
||||
await this.addParticipantFromTraining(participant);
|
||||
}
|
||||
|
||||
// Lade Turnierdaten neu
|
||||
await this.loadTournamentData();
|
||||
} else {
|
||||
alert('Keine gültigen Teilnehmer im heutigen Trainingstag gefunden!');
|
||||
}
|
||||
} else {
|
||||
alert('Keine Teilnehmer im heutigen Trainingstag gefunden!');
|
||||
}
|
||||
} else {
|
||||
alert('Kein Trainingstag für heute gefunden!');
|
||||
}
|
||||
} else {
|
||||
alert('Kein Trainingstag für heute gefunden!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Trainingsteilnehmer:', error);
|
||||
alert('Fehler beim Laden der Trainingsteilnehmer: ' + (error.response?.data?.error || error.message));
|
||||
}
|
||||
},
|
||||
|
||||
async addParticipantFromTraining(participant) {
|
||||
try {
|
||||
await apiClient.post('/tournament/participant', {
|
||||
clubId: this.currentClub,
|
||||
tournamentId: this.selectedDate,
|
||||
participant: participant.clubMemberId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Hinzufügen des Teilnehmers:', error);
|
||||
// Ignoriere Fehler, da Teilnehmer möglicherweise bereits existiert
|
||||
}
|
||||
},
|
||||
|
||||
getMatchDisplayText(player1Id, player2Id, groupId) {
|
||||
const liveResult = this.getMatchLiveResult(player1Id, player2Id, groupId);
|
||||
if (!liveResult) return '-';
|
||||
|
||||
// Zeige Satzergebnis (z.B. 1:0, 2:1, 0:2)
|
||||
return `${liveResult.sets1}:${liveResult.sets2}`;
|
||||
},
|
||||
|
||||
getMatchCellClasses(player1Id, player2Id, groupId) {
|
||||
const liveResult = this.getMatchLiveResult(player1Id, player2Id, groupId);
|
||||
if (!liveResult) return ['no-match'];
|
||||
|
||||
const classes = ['match-result'];
|
||||
|
||||
if (liveResult.isFinished) {
|
||||
// Spiel beendet: Dunkle Farben
|
||||
if (liveResult.player1Won) {
|
||||
classes.push('match-finished-win');
|
||||
} else if (liveResult.player2Won) {
|
||||
classes.push('match-finished-loss');
|
||||
} else {
|
||||
classes.push('match-finished-tie');
|
||||
}
|
||||
} else {
|
||||
// Spiel läuft: Helle Farben
|
||||
if (liveResult.player1Won) {
|
||||
classes.push('match-live-win');
|
||||
} else if (liveResult.player2Won) {
|
||||
classes.push('match-live-loss');
|
||||
} else {
|
||||
classes.push('match-live-tie');
|
||||
}
|
||||
}
|
||||
|
||||
return classes;
|
||||
},
|
||||
|
||||
getLivePosition(playerId, groupId) {
|
||||
// Berechne Live-Punkte für alle Spieler in der Gruppe
|
||||
const groupPlayers = this.groupRankings[groupId] || [];
|
||||
const liveStats = groupPlayers.map(player => {
|
||||
let livePoints = player.points || 0;
|
||||
let liveSetsWon = player.setsWon || 0;
|
||||
let liveSetsLost = player.setsLost || 0;
|
||||
|
||||
// Füge Live-Punkte aus begonnenen (aber nicht abgeschlossenen) Spielen hinzu
|
||||
const playerMatches = this.matches.filter(m =>
|
||||
m.round === 'group' &&
|
||||
m.groupId === groupId &&
|
||||
(m.player1.id === player.id || m.player2.id === player.id) &&
|
||||
!m.isFinished && // Nur begonnene, nicht abgeschlossene Spiele
|
||||
m.tournamentResults && m.tournamentResults.length > 0 // Mit Satz-Ergebnissen
|
||||
);
|
||||
|
||||
playerMatches.forEach(match => {
|
||||
const isPlayer1 = match.player1.id === player.id;
|
||||
const results = match.tournamentResults || [];
|
||||
|
||||
if (results.length > 0) {
|
||||
let setsWon = 0, setsLost = 0;
|
||||
results.forEach(result => {
|
||||
if (isPlayer1) {
|
||||
if (result.pointsPlayer1 > result.pointsPlayer2) setsWon++;
|
||||
else if (result.pointsPlayer2 > result.pointsPlayer1) setsLost++;
|
||||
} else {
|
||||
if (result.pointsPlayer2 > result.pointsPlayer1) setsWon++;
|
||||
else if (result.pointsPlayer1 > result.pointsPlayer2) setsLost++;
|
||||
}
|
||||
});
|
||||
|
||||
// Füge Live-Punkte hinzu (basierend auf aktuellen Sätzen)
|
||||
if (setsWon > setsLost) {
|
||||
livePoints += 1;
|
||||
} else if (setsLost > setsWon) {
|
||||
livePoints -= 1;
|
||||
}
|
||||
|
||||
// Füge Live-Sätze hinzu
|
||||
if (isPlayer1) {
|
||||
liveSetsWon += results.reduce((sum, r) => sum + r.pointsPlayer1, 0);
|
||||
liveSetsLost += results.reduce((sum, r) => sum + r.pointsPlayer2, 0);
|
||||
} else {
|
||||
liveSetsWon += results.reduce((sum, r) => sum + r.pointsPlayer2, 0);
|
||||
liveSetsLost += results.reduce((sum, r) => sum + r.pointsPlayer1, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
livePoints: livePoints,
|
||||
liveSetsWon: liveSetsWon,
|
||||
liveSetsLost: liveSetsLost,
|
||||
liveSetDiff: liveSetsWon - liveSetsLost
|
||||
};
|
||||
});
|
||||
|
||||
// Sortiere nach Live-Punkten (absteigend), dann nach Satzverhältnis
|
||||
liveStats.sort((a, b) => {
|
||||
if (b.livePoints !== a.livePoints) return b.livePoints - a.livePoints;
|
||||
if (b.liveSetDiff !== a.liveSetDiff) return b.liveSetDiff - a.liveSetDiff;
|
||||
if (b.liveSetsWon !== a.liveSetsWon) return b.liveSetsWon - a.liveSetsWon;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Finde Position des Spielers
|
||||
const position = liveStats.findIndex(p => p.id === playerId) + 1;
|
||||
return position;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -822,4 +1308,193 @@ td {
|
||||
button {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.diagonal {
|
||||
background-color: #000;
|
||||
color: #000;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.match-result {
|
||||
font-weight: bold;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.match-result.win {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.match-result.loss {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
/* Live-Ergebnisse während des Spiels */
|
||||
.match-live-win {
|
||||
background-color: #d1eca1; /* Hellgrün */
|
||||
color: #0c5460;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.match-live-loss {
|
||||
background-color: #ffeaa7; /* Orange */
|
||||
color: #d63031;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.match-live-tie {
|
||||
background-color: #f8f9fa; /* Neutral */
|
||||
color: #6c757d;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Beendete Spiele */
|
||||
.match-finished-win {
|
||||
background-color: #28a745; /* Dunkelgrün */
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.match-finished-loss {
|
||||
background-color: #fd7e14; /* Dunkelorange */
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.match-finished-tie {
|
||||
background-color: #6c757d; /* Grau */
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Kollabierbare Teilnehmerliste */
|
||||
.participants-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.participants-header:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.participants-header h4 {
|
||||
margin: 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 0.8em;
|
||||
color: #6c757d;
|
||||
transition: transform 0.3s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.collapse-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.participants-content {
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 1000px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-participant {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.training-btn {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.training-btn:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.training-btn:active {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
|
||||
.no-match {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.group-table table {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.group-table th,
|
||||
.group-table td {
|
||||
padding: 0.3em 0.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.group-table th:first-child,
|
||||
.group-table td:first-child {
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.group-table th:nth-child(3),
|
||||
.group-table td:nth-child(3) {
|
||||
text-align: left;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.match-cell.clickable {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.match-cell.clickable:hover {
|
||||
background-color: #f8f9fa;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.match-highlight {
|
||||
background-color: #fff3cd !important;
|
||||
border: 2px solid #ffc107 !important;
|
||||
animation: highlight-pulse 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes highlight-pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.02); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@@ -23,21 +23,48 @@
|
||||
<table class="members-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th @click="sortBy('name')" class="sortable-header">
|
||||
<div class="header-content">
|
||||
<span>Name</span>
|
||||
<span class="sort-icon">{{ getSortIcon('name') }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>Geburtsdatum</th>
|
||||
<th>Teilnahmen (12 Monate)</th>
|
||||
<th>Teilnahmen (3 Monate)</th>
|
||||
<th>Teilnahmen (Gesamt)</th>
|
||||
<th @click="sortBy('participation12Months')" class="sortable-header">
|
||||
<div class="header-content">
|
||||
<span>Teilnahmen (12 Monate)</span>
|
||||
<span class="sort-icon">{{ getSortIcon('participation12Months') }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th @click="sortBy('participation3Months')" class="sortable-header">
|
||||
<div class="header-content">
|
||||
<span>Teilnahmen (3 Monate)</span>
|
||||
<span class="sort-icon">{{ getSortIcon('participation3Months') }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th @click="sortBy('participationTotal')" class="sortable-header">
|
||||
<div class="header-content">
|
||||
<span>Teilnahmen (Gesamt)</span>
|
||||
<span class="sort-icon">{{ getSortIcon('participationTotal') }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th @click="sortBy('lastTrainingTs')" class="sortable-header">
|
||||
<div class="header-content">
|
||||
<span>Letztes Training</span>
|
||||
<span class="sort-icon">{{ getSortIcon('lastTrainingTs') }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in activeMembers" :key="member.id" class="member-row">
|
||||
<tr v-for="member in sortedMembers" :key="member.id" class="member-row">
|
||||
<td>{{ member.firstName }} {{ member.lastName }}</td>
|
||||
<td>{{ formatBirthdate(member.birthDate) }}</td>
|
||||
<td>{{ member.participation12Months }}</td>
|
||||
<td>{{ member.participation3Months }}</td>
|
||||
<td>{{ member.participationTotal }}</td>
|
||||
<td>{{ formatDate(member.lastTraining) || '-' }}</td>
|
||||
<td>
|
||||
<button @click="showMemberDetails(member)" class="btn-primary btn-small">
|
||||
Details anzeigen
|
||||
@@ -108,6 +135,32 @@ export default {
|
||||
if (this.activeMembers.length === 0) return 0;
|
||||
const total = this.activeMembers.reduce((sum, member) => sum + member.participation3Months, 0);
|
||||
return total / this.activeMembers.length;
|
||||
},
|
||||
|
||||
sortedMembers() {
|
||||
if (!this.activeMembers.length) return [];
|
||||
|
||||
return [...this.activeMembers].sort((a, b) => {
|
||||
let aValue, bValue;
|
||||
|
||||
if (this.sortField === 'name') {
|
||||
// Case-insensitive Namens-Sortierung mit localeCompare
|
||||
// Sortiere nach firstName lastName (wie in der Anzeige)
|
||||
const aName = `${a.firstName} ${a.lastName}`;
|
||||
const bName = `${b.firstName} ${b.lastName}`;
|
||||
return aName.localeCompare(bName, 'de', { sensitivity: 'base' }) * (this.sortDirection === 'asc' ? 1 : -1);
|
||||
} else {
|
||||
// Numerische Sortierung
|
||||
aValue = a[this.sortField] || 0;
|
||||
bValue = b[this.sortField] || 0;
|
||||
|
||||
if (this.sortDirection === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -116,7 +169,9 @@ export default {
|
||||
activeMembers: [],
|
||||
showDetailsModal: false,
|
||||
selectedMember: {},
|
||||
loading: false
|
||||
loading: false,
|
||||
sortField: 'name',
|
||||
sortDirection: 'asc'
|
||||
};
|
||||
},
|
||||
|
||||
@@ -177,6 +232,24 @@ export default {
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
|
||||
},
|
||||
|
||||
sortBy(field) {
|
||||
if (this.sortField === field) {
|
||||
// Wenn bereits nach diesem Feld sortiert wird, Richtung umkehren
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// Neues Feld, Standardrichtung setzen
|
||||
this.sortField = field;
|
||||
this.sortDirection = 'desc';
|
||||
}
|
||||
},
|
||||
|
||||
getSortIcon(field) {
|
||||
if (this.sortField !== field) {
|
||||
return '↕️'; // Neutral
|
||||
}
|
||||
return this.sortDirection === 'asc' ? '↑' : '↓';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -245,6 +318,29 @@ export default {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sortable-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-header:hover {
|
||||
background: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.members-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
Reference in New Issue
Block a user