55 Commits

Author SHA1 Message Date
Torsten Schulz (local)
144034a305 Verbessert die Benutzeroberfläche in ScheduleView.vue durch Hinzufügen von Funktionen zum Laden von Gesamt- und Erwachsenenspielplänen. Implementiert eine Hover-Info für Spiele mit Fallback-Werten und optimiert die Anzeige von Spielinformationen. Fügt neue CSS-Klassen für die Hervorhebung von Spielen heute und in der nächsten Woche hinzu. 2025-09-25 19:45:20 +02:00
Torsten Schulz (local)
09ffd1db3d Fügt die Funktionalität zum schnellen Hinzufügen von Mitgliedern in DiaryView.vue hinzu. Implementiert einen Dialog zur Eingabe von Mitgliedsdaten, einschließlich Vorname, Nachname, Geburtsdatum und Geschlecht. Aktualisiert die Logik zur Validierung neuer Mitglieder und zur Integration in die Mitgliederliste. 2025-09-21 19:25:30 +02:00
Torsten Schulz (local)
d90acf43e1 Verbessert die Benutzeroberfläche in OfficialTournaments.vue durch Anpassung der PDF-Generierungsfunktion. Die Schaltfläche zum Erzeugen von PDFs schließt nun das Dialogfeld automatisch. Aktualisiert die Logik zur Auswahl von Mitgliedern, um die ausgewählte ID beim Ändern des Status zu aktualisieren. 2025-09-21 18:49:10 +02:00
Torsten Schulz (local)
adb93af906 Fügt die Unterstützung für Teilnahmegebühren in officialTournamentController.js hinzu, einschließlich der Extraktion von Gebühren aus dem Turniertext. Aktualisiert das Datenmodell in OfficialTournament.js, um die Teilnahmegebühren zu speichern. Passt die Benutzeroberfläche in OfficialTournaments.vue an, um die Teilnahmegebühren anzuzeigen, und aktualisiert PDFGenerator.js, um die Gebühren im PDF-Dokument darzustellen. 2025-09-21 18:39:25 +02:00
Torsten Schulz (local)
a36f0ea446 Aktualisiert die Benutzeroberfläche in OfficialTournaments.vue zur Anzeige des Teilnehmerstatus mit neuen Status-Badges und Aktionsbuttons. Implementiert die Logik zur Aktualisierung des Status eines Teilnehmers, einschließlich der Optionen Anmelden, Teilnehmen und Zurücksetzen. Verbessert die Darstellung der Platzierungseingabe und optimiert das Styling für eine bessere Benutzererfahrung. 2025-09-21 18:11:16 +02:00
Torsten Schulz (local)
e4fcf2eca2 Fügt die Funktionalität zur Aktualisierung des Teilnehmerstatus in officialTournamentController.js hinzu. Implementiert die Route zum Aktualisieren des Status eines Teilnehmers in officialTournamentRoutes.js und passt die Benutzeroberfläche in OfficialTournaments.vue an, um den neuen Status anzuzeigen und Aktionen wie Anmelden, Teilnehmen und Zurücksetzen zu ermöglichen. 2025-09-21 18:05:50 +02:00
Torsten Schulz (local)
0ee16c7766 Fügt detaillierte Konsolenausgaben in TournamentService.js hinzu, um den Prozess der Match-Erstellung für Gruppen zu verfolgen. Aktualisiert das Styling in main.scss, um die Schriftartgewichtung auf 700 zu erhöhen und die Texttransformation zu entfernen. 2025-09-21 17:49:41 +02:00
Torsten Schulz (local)
21c19298da Fügt die Möglichkeit hinzu, Teilnehmer aus dem heutigen Trainingstag zu laden, einschließlich der Logik zur Überprüfung, ob heute ein Trainingstag stattfindet. Implementiert die Methode zum Laden der Teilnehmer und optimiert die Benutzeroberfläche mit einem neuen Button für diese Funktion. 2025-09-21 17:44:39 +02:00
Torsten Schulz (local)
3c65fed994 Fügt die Funktion zum Laden von Turnieren beim Start hinzu und optimiert die Turniererstellung, um die Turnierliste nach der Erstellung eines neuen Turniers automatisch zu aktualisieren. Verbessert die Anzeige von Turnierdaten, indem der Turniername priorisiert wird, und behandelt Fehler beim Laden und Erstellen von Turnieren. 2025-09-21 17:31:12 +02:00
Torsten Schulz (local)
66046ddccd Aktualisiert die Punktevergabe in TournamentService.js und TournamentsView.vue, sodass der Sieger +1 Punkt erhält und der Verlierer -1 Punkt. Fügt eine neue Methode getLivePosition hinzu, um die Live-Punkte und -Sätze der Spieler in der Gruppe zu berechnen und anzuzeigen. Optimiert die Darstellung der Platzierung in der Tabelle. 2025-09-21 17:25:23 +02:00
Torsten Schulz (local)
561d8186d3 Verbessert die Logik zur Zuordnung von Teilnehmern in TournamentService.js, indem manuelle Zuordnungen berücksichtigt werden. Implementiert eine zufällige Verteilung der Teilnehmer nur, wenn keine manuelle Zuordnung vorhanden ist. Aktualisiert die Erstellung von Matches, um sicherzustellen, dass nur Spieler aus derselben Gruppe gegeneinander antreten. In TournamentsView.vue wird die Teilnehmerliste jetzt kollabierbar, und es werden neue Funktionen zur Anzeige von Spielergebnissen und zur Hervorhebung von Matches hinzugefügt. 2025-09-21 17:16:47 +02:00
Torsten Schulz (local)
312f8f24ab Optimiert das Styling in DiaryView.vue, indem die Bildgrößenanpassung auf viewport-basierte Einheiten umgestellt wird. Entfernt Margen und Polsterungen für eine bessere Darstellung der Bilder im Overlay. 2025-09-16 00:16:54 +02:00
Torsten Schulz (local)
ba4b56360d Optimiert die Darstellung von vordefinierten Aktivitäten in DiaryView.vue, indem die Logik zur Anzeige von Aktivitätsnamen und -codes verbessert wird. Aktualisiert das Styling der Bildsymbole und passt die Positionierung der Bilder an, um eine bessere Benutzererfahrung zu gewährleisten. 2025-09-16 00:14:30 +02:00
Torsten Schulz (local)
02732a01da Fügt die Möglichkeit hinzu, Bilder vordefinierter Aktivitäten in DiaryView.vue anzuzeigen. Implementiert die Methode showActivityImage zur Anzeige des Bildes und aktualisiert das Styling für die Bildsymbole. 2025-09-15 23:58:48 +02:00
Torsten Schulz (local)
4307fa7d82 Entfernt die Authentifizierung von der Route zum Abrufen von vordefinierten Aktivitätsbildern in predefinedActivityRoutes.js. Aktualisiert den Alt-Text für Bilder in PredefinedActivities.vue von "Activity Image" auf "Predefined Activity Image". 2025-09-15 23:56:18 +02:00
Torsten Schulz (local)
a1dc6afb2c Ändert die Zugriffskontrolle in predefinedActivityImageController.js von checkAccess zu checkGlobalAccess, um die globale Authentifizierung für vordefinierte Aktivitäten zu ermöglichen. Fügt die Funktion checkGlobalAccess in userUtils.js hinzu, die die Benutzerinformationen basierend auf dem Token zurückgibt. 2025-09-15 23:53:49 +02:00
Torsten Schulz (local)
92ce64b807 Fügt die Funktion zum Löschen von vordefinierten Aktivitätsbildern hinzu. Implementiert die Logik in der Datei predefinedActivityImageController.js und aktualisiert die Routen in predefinedActivityRoutes.js. Ergänzt die Benutzeroberfläche in PredefinedActivities.vue um die Möglichkeit, hochgeladene Bilder anzuzeigen und zu löschen. 2025-09-15 23:46:59 +02:00
Torsten Schulz (local)
296939d1a0 Entfernt die Deaktivierung des "Teilnehmer-PDF"-Buttons in OfficialTournaments.vue, um die Benutzerfreundlichkeit zu verbessern und die PDF-Generierung jederzeit zu ermöglichen. 2025-09-12 14:30:53 +02:00
Torsten Schulz (local)
dc8a5778d6 Implementiert die Funktion zur Generierung eines Teilnehmer-PDFs in OfficialTournaments.vue. Fügt die Methode addParticipantsSummary in PDFGenerator.js hinzu, um eine Zusammenfassung der Teilnehmerdaten in einem PDF-Dokument darzustellen. Integriert die Logik zur Gruppierung und Formatierung der Teilnehmerinformationen basierend auf ihrem Anmeldestatus und der Teilnahme. 2025-09-12 14:23:47 +02:00
Torsten Schulz (local)
cf04e5bfe8 Erweitert die Benutzeroberfläche in OfficialTournaments.vue um einen neuen Tab für Teilnehmer, einschließlich Filteroptionen zur Anzeige von Anmeldestatus und Teilnahme. Implementiert die Logik zur Gruppierung und Anzeige der Teilnehmerdaten in einer Tabelle. 2025-09-12 13:58:04 +02:00
Torsten Schulz (local)
ace15ae1d3 Aktualisiert die index.html zur Unterstützung der deutschen Sprache und verbessert die SEO durch Hinzufügen von Meta-Tags. Modifiziert App.vue, um das Logo in der Kopfzeile anzuzeigen und fügt einen Footer mit Links zu Impressum und Datenschutzerklärung hinzu. Überarbeitet Home.vue mit neuen Marketing- und Funktionsabschnitten sowie einer FAQ-Sektion zur Benutzerinformation. Ergänzt Router-Konfiguration um Impressum- und Datenschutzseiten. 2025-09-11 15:32:49 +02:00
Torsten Schulz (local)
d4b82a3a6f Erweitert die Methode eligibleMembers in OfficialTournaments.vue, um nur aktive Mitglieder zu filtern, die für Wettbewerbe berechtigt sind. Dies verbessert die Genauigkeit der angezeigten Teilnehmerliste. 2025-09-11 14:58:33 +02:00
Torsten Schulz (local)
48cd0921df Fügt die Methode listClubParticipations im OfficialTournamentController hinzu, um die Teilnahme von Mitgliedern an offiziellen Turnieren zu listen. Aktualisiert die Routen, um diese neue Funktionalität zu integrieren. Verbessert die Benutzeroberfläche in OfficialTournaments.vue mit Tabs zur Anzeige von Veranstaltungen und Turnierbeteiligungen sowie einer Filteroption für den Zeitraum der Beteiligungen. 2025-09-11 14:11:19 +02:00
Torsten Schulz (local)
df02e48cfd Fügt das Modell OfficialCompetitionMember hinzu und implementiert die Logik zur Verwaltung der Teilnahme von Mitgliedern an offiziellen Wettbewerben. Aktualisiert die Routen und Controller, um die Teilnahmeinformationen zu speichern und abzurufen. Ergänzt die Benutzeroberfläche in OfficialTournaments.vue zur Anzeige und Bearbeitung der Teilnahmeoptionen für Mitglieder. 2025-09-11 12:58:56 +02:00
Torsten Schulz (local)
4a6d868820 Ändert die Schriftgröße der Navigationslinks in App.vue von 0.75rem auf 1rem, um die Lesbarkeit zu verbessern. 2025-09-01 11:38:44 +02:00
Torsten Schulz (local)
52556a4292 Fügt ein neues Skript zur Bereinigung aller Indizes in package.json hinzu und entfernt überflüssige Leerzeichen in diaryDateActivityService.js. 2025-09-01 11:27:09 +02:00
Torsten Schulz (server)
3a02ffb3e3 Merge branch 'main' of ssh://tsschulz.de:/home/git/trainingstagebuch 2025-09-01 09:23:26 +00:00
Torsten Schulz (local)
c4b9a7d782 Verbessert die Benutzeroberfläche in DiaryView.vue, indem die Struktur des Unfallformulars optimiert und die Audioinitialisierung an die Benutzerinteraktion angepasst wird. Fügt Logik zur Überprüfung von Aktivitätszeiten hinzu und stellt sicher, dass Audio nur bei aktivierter Funktion abgespielt wird. 2025-09-01 11:23:02 +02:00
Torsten Schulz (server)
5e8b221541 Merge branch 'main' of ssh://tsschulz.de:/home/git/trainingstagebuch 2025-09-01 07:38:48 +00:00
Torsten Schulz (server)
26720c8df3 updated package.json 2025-09-01 07:38:43 +00:00
Torsten Schulz (local)
a1ab742126 Optimiert das Styling in DiaryView.vue, indem die Überlauf-Eigenschaften des Trainingsplan-Div-Elements angepasst werden. Entfernt die horizontale Überlauf-Einstellung, um die Benutzeroberfläche zu verbessern. 2025-09-01 09:37:43 +02:00
Torsten Schulz (local)
f21ad3d8a3 Fügt eine neue Skriptfunktion zum Bereinigen von Benutzertoken hinzu und aktualisiert die Logik zum Synchronisieren des UserToken-Modells. Implementiert eine neue Controller-Methode zum Löschen von Datumsangaben für Clubs und passt die Routen entsprechend an. Ergänzt die Benutzeroberfläche in DiaryView.vue um die Möglichkeit, ein Datum zu löschen, und aktualisiert die Logik zur Überprüfung der Datumsaktualität. 2025-09-01 09:33:54 +02:00
Torsten Schulz (local)
51d3087006 Fügt die Anzeige des letzten Trainingsdatums und einen Sortiermechanismus in der Mitgliederstatistik hinzu. Aktualisiert die Backend-Logik zur Berechnung des letzten Trainings und passt die Benutzeroberfläche in TrainingStatsView.vue entsprechend an. 2025-08-31 21:32:03 +02:00
Torsten Schulz (local)
a08588a075 Aktualisiert die Benutzeroberfläche in PredefinedActivities.vue, um die Auswahlmöglichkeiten für das Zusammenführen von Aktivitäten zu verbessern. Sortiert die Aktivitäten in den Dropdown-Listen nach Namen, um die Benutzerfreundlichkeit zu erhöhen. 2025-08-31 21:19:51 +02:00
Torsten Schulz (local)
5d67a52b45 Verbessert das Styling in PredefinedActivities.vue, um die Benutzeroberfläche zu optimieren. Fügt eine Höhe für das Hauptlayout hinzu und ermöglicht das Scrollen in der Liste sowie eine sticky Positionierung für die Toolbar, um die Benutzerfreundlichkeit zu erhöhen. 2025-08-31 21:14:30 +02:00
Torsten Schulz (local)
f29425c987 Fügt Funktionen zum Zusammenführen und Entfernen von Duplikaten vordefinierter Aktivitäten hinzu. Implementiert die entsprechenden Controller-Methoden und Routen. Aktualisiert die Benutzeroberfläche in PredefinedActivities.vue, um die neuen Funktionen zur Verfügung zu stellen und die Aktivitäten nach Namen und Code zu sortieren. 2025-08-31 21:09:48 +02:00
Torsten Schulz (local)
e3b8488d2b Erweitert die PDF-Generierung in PDFGenerator.js, um empfohlene und andere Wettbewerbe für Mitglieder zu unterscheiden. Fügt eine neue Struktur für die Anzeige von Empfehlungen und Hinweisen hinzu. Aktualisiert OfficialTournaments.vue, um die Auswahl von Mitgliedern und deren Wettbewerben zu verbessern, einschließlich einer neuen Dialogstruktur und der Verwaltung von Empfehlungen. 2025-08-31 15:55:49 +02:00
Torsten Schulz (local)
f49e1896b9 Fügt eine Funktion zur PDF-Generierung für ausgewählte Mitglieder in OfficialTournaments.vue hinzu. Implementiert ein Dialogfeld zur Auswahl von Mitgliedern und ermöglicht die Erstellung eines PDFs mit Wettbewerbsinformationen. Aktualisiert das Styling für die Benutzeroberfläche und die Modal-Komponenten. 2025-08-31 15:28:46 +02:00
Torsten Schulz (local)
2092473cf3 Verbessert die Mitgliederansicht in MembersView.vue, indem inaktive Mitglieder visuell hervorgehoben werden. Fügt CSS-Klassen hinzu, um die Darstellung inaktiver Mitglieder zu optimieren, einschließlich einer inaktiven Auszeichnung und einer durchgestrichenen Schriftart für inaktive Geschlechtssymbole und -bezeichnungen. 2025-08-31 15:11:01 +02:00
Torsten Schulz (local)
c00849a154 Verbessert die Mitgliederansicht in ClubView.vue, indem aktive Mitglieder nach Nachnamen und Vornamen sortiert angezeigt werden. Fügt Geschlechtssymbole und -bezeichnungen hinzu, um die Darstellung zu optimieren. Implementiert neue Methoden zur Geschlechtslabelierung und -symbolisierung sowie entsprechende CSS-Klassen für eine ansprechendere Benutzeroberfläche. 2025-08-31 15:02:57 +02:00
Torsten Schulz (local)
8069946154 Aktualisiert die Mitgliederansicht in MembersView.vue, um Geschlechtssymbole und -bezeichnungen anzuzeigen. Entfernt die Geschlechtsspalte und implementiert neue Methoden zur Darstellung von Geschlecht. Fügt CSS-Klassen für die Geschlechtsdarstellung hinzu, um die Benutzeroberfläche zu verbessern. 2025-08-30 23:39:28 +02:00
Torsten Schulz (local)
975800c1ab Fügt Unterstützung für offizielle Turniere und Wettbewerbe hinzu. Aktualisiert die Datenbankmodelle, um Geschlecht für Mitglieder zu erfassen, und implementiert neue Routen sowie Frontend-Komponenten zur Anzeige und Verwaltung dieser Daten. Verbessert die Benutzeroberfläche zur Eingabe von Mitgliederdaten und aktualisiert die Abhängigkeiten im Projekt. 2025-08-30 23:16:39 +02:00
Torsten Schulz (local)
b82a80a11d Fügt Unterstützung für Aktivitätenmitglieder in DiaryView.vue hinzu. Ermöglicht das Zuordnen von Teilnehmern zu Aktivitäten, einschließlich der Verwaltung von Teilnehmern über das Backend. Aktualisiert die Datenbankmodelle und -routen, um die neuen Funktionen zu unterstützen. 2025-08-28 14:43:04 +02:00
Torsten Schulz (local)
244b61c901 Fügt Unterstützung für vordefinierte Aktivitäten hinzu, einschließlich der Möglichkeit, Bilder hochzuladen und zu suchen. Aktualisiert die Datenbankmodelle und -routen entsprechend. Verbessert die Benutzeroberfläche zur Anzeige und Bearbeitung von Aktivitäten in DiaryView.vue. 2025-08-28 14:11:29 +02:00
Torsten Schulz (local)
c7325ac982 Erweitert die updateActivity-Methode in DiaryDateActivityService, um benutzerdefinierte Aktivitäten zu verarbeiten und neue vordefinierte Aktivitäten zu erstellen. Fügt eine Methode loadTrainingPlan in DiaryView.vue hinzu, um die Anzeige nach Änderungen zu aktualisieren. 2025-08-28 13:14:06 +02:00
Torsten Schulz (local)
8fbdc68016 Implementiert die Bearbeitung von Aktivitäten in DiaryView.vue. Fügt Eingabefelder und Schaltflächen zum Speichern oder Abbrechen von Änderungen hinzu. Aktualisiert das Styling für klickbare Elemente. 2025-08-28 12:04:33 +02:00
Torsten Schulz (local)
455b2c94cd Aktualisiert die Anzeige der Teilnehmeranzahl in DiaryView.vue, indem die Variable von 'members' auf 'participants' geändert wurde. 2025-08-28 09:31:24 +02:00
Torsten Schulz (local)
c9a1026b50 Aktualisiert die Anzeige der Teilnehmeranzahl in DiaryView.vue, indem die Anzahl der Mitglieder in der Überschrift angezeigt wird. 2025-08-28 09:28:00 +02:00
Torsten Schulz (local)
f6f1ea0403 Erhöht das Padding am unteren Rand der Spalte in DiaryView.vue von 3em auf 4em, um das Layout weiter zu optimieren. 2025-08-28 09:25:54 +02:00
Torsten Schulz (local)
a636b32510 Fügt Padding zum unteren Rand der Spalte in DiaryView.vue hinzu, um das Layout zu verbessern. 2025-08-28 09:22:26 +02:00
Torsten Schulz (local)
8ee1203ec6 Implementiert Benutzer-Authentifizierung und Datenladung bei Login. Fügt Links für Registrierung und Login in den entsprechenden Komponenten hinzu. Aktualisiert das Styling für Login- und Registrierungslinks. 2025-08-27 09:10:53 +02:00
Torsten Schulz (local)
bce5150757 Aktualisiert die Datenbankkonfiguration und ändert den Import von Komponenten in SCSS auf die neue Syntax. 2025-08-23 22:02:20 +02:00
Torsten Schulz (local)
117f6b4c93 Fügt Sortierfunktion für Ligen hinzu und entfernt die automatische Neuladung bei Logout 2025-08-23 21:57:55 +02:00
Torsten Schulz (local)
6a8b0e35d7 Fügt Sortierfunktionalität für die Mitgliederstatistik hinzu. Die Tabellenüberschriften sind jetzt klickbar und ermöglichen eine Sortierung nach Name und Teilnahmezahlen. Zudem wurde die Sortierreihenfolge implementiert und visuell durch Icons angezeigt. 2025-08-22 16:06:56 +02:00
Torsten Schulz (local)
ed96fc5f27 Aktualisiert den TrainingStatsController, um nur aktive Mitglieder eines spezifischen Vereins zu laden, indem die Abfrage um die clubId erweitert wurde. 2025-08-22 15:53:50 +02:00
65 changed files with 6687 additions and 505 deletions

View File

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

View 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' });
}
};

View File

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

View 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 },
};
}

View File

@@ -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 } = req.body;
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink });
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 } = req.body;
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink });
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' });
}
};

View File

@@ -0,0 +1,92 @@
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);
const imageRecord = await PredefinedActivityImage.create({
predefinedActivityId: id,
imagePath: filePath,
mimeType: 'image/jpeg',
});
// 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' });
}
};

View File

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

View File

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

View 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;

View File

@@ -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,

View 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;

View File

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

View File

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

View File

@@ -11,6 +11,10 @@ const PredefinedActivity = sequelize.define('PredefinedActivity', {
type: DataTypes.STRING,
allowNull: false,
},
code: {
type: DataTypes.STRING,
allowNull: true,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
@@ -23,6 +27,10 @@ const PredefinedActivity = sequelize.define('PredefinedActivity', {
type: DataTypes.INTEGER,
allowNull: true,
},
imageLink: {
type: DataTypes.STRING,
allowNull: true,
},
}, {
tableName: 'predefined_activities',
timestamps: true,

View File

@@ -0,0 +1,30 @@
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,
},
}, {
tableName: 'predefined_activity_images',
timestamps: true,
underscored: true,
});
export default PredefinedActivityImage;

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -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
View File

@@ -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]';
}
/**

View File

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

View File

@@ -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
==========

View File

@@ -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",

View File

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

View File

@@ -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"

View File

@@ -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": {

View File

@@ -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"
},

View 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;

View File

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

View 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;

View File

@@ -4,13 +4,40 @@ 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.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;

View 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();

View 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();

View File

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

View File

@@ -20,9 +20,9 @@ class DiaryDateActivityService {
duration: data.duration
});
}
restData.predefinedActivityId = predefinedActivity.id;
restData.predefinedActivityId = predefinedActivity.id;
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 +39,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: '',
duration: data.duration || activity.duration
});
}
// 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);
}

View File

@@ -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();

View File

@@ -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');

View File

@@ -1,13 +1,20 @@
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,
});
}
@@ -20,15 +27,23 @@ class PredefinedActivityService {
}
return await activity.update({
name: data.name,
code: data.code,
description: data.description,
durationText: data.durationText,
duration: data.duration,
imageLink: data.imageLink,
});
}
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 +55,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();

View File

@@ -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) RoundRobin anlegen wie gehabt
// 4) RoundRobin 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;

View File

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

View File

@@ -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 WebApp." />
<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 DSGVOfreundlich 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 DSGVOfreundlich 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>

View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://tt-tagebuch.de/sitemap.xml

View 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>

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@@ -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({

View File

@@ -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) {

View File

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

View 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

View File

@@ -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 DSGVOkonform 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 EMail.</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 WebAnwendung. 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 WebAnwendung. 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 {

View 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>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
<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 oder ein Bild hochladen:</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>
<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';
export default {
name: 'PredefinedActivities',
data() {
return {
activities: [],
selectedActivity: null,
editModel: null,
images: [],
selectedFile: 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: {
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 || [];
this.editModel = { ...activity };
},
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: '',
};
},
cancel() {
this.editModel = null;
this.selectedActivity = null;
this.images = [];
},
async save() {
if (!this.editModel) return;
if (this.editModel.id) {
const { id, ...payload } = this.editModel;
const r = await apiClient.put(`/predefined-activities/${id}`, payload);
this.editModel = r.data;
} else {
const r = await apiClient.post('/predefined-activities', this.editModel);
this.editModel = r.data;
// Nach dem Erstellen einer neuen Aktivität, falls ein Bild ausgewählt wurde, hochladen
if (this.selectedFile) {
await this.uploadImage();
}
}
await this.reload();
},
onFileChange(e) {
this.selectedFile = e.target.files && e.target.files[0] ? e.target.files[0] : null;
},
imageUrl(img) {
return `/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
},
async uploadImage() {
if (!this.editModel || !this.editModel.id || !this.selectedFile) return;
const fd = new FormData();
fd.append('image', this.selectedFile);
await apiClient.post(`/predefined-activities/${this.editModel.id}/image`, fd, {
headers: { 'Content-Type': 'multipart/form-data' }
});
// Nach Upload Details neu laden
await this.select(this.editModel);
this.selectedFile = null;
},
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();
}
},
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;
}
</style>

View File

@@ -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');

View File

@@ -2,13 +2,16 @@
<div>
<h2>Spielpläne</h2>
<button @click="openImportModal">Spielplanimport</button>
<div v-if="hoveredMatch" class="hover-info">
<p><strong>{{ hoveredMatch.location.name }}</strong></p>
<p>{{ hoveredMatch.location.address }}</p>
<p>{{ hoveredMatch.location.zip }} {{ hoveredMatch.location.city }}</p>
<div v-if="hoveredMatch && hoveredMatch.location" class="hover-info">
<p><strong>{{ hoveredMatch.location.name || 'N/A' }}</strong></p>
<p>{{ hoveredMatch.location.address || 'N/A' }}</p>
<p>{{ hoveredMatch.location.zip || '' }} {{ hoveredMatch.location.city || 'N/A' }}</p>
</div>
<div class="output">
<ul>
<li class="special-link" @click="loadAllMatches">Gesamtspielplan</li>
<li class="special-link" @click="loadAdultMatches">Spielplan Erwachsene</li>
<li class="divider"></li>
<li v-for="league in leagues" :key="league" @click="loadMatchesForLeague(league.id, league.name)">{{
league.name }}</li>
</ul>
@@ -23,15 +26,17 @@
<th>Uhrzeit</th>
<th>Heimmannschaft</th>
<th>Gastmannschaft</th>
<th v-if="selectedLeague === 'Gesamtspielplan' || selectedLeague === 'Spielplan Erwachsene'">Altersklasse</th>
</tr>
</thead>
<tbody>
<tr v-for="match in matches" :key="match.id" @mouseover="hoveredMatch = match"
@mouseleave="hoveredMatch = null">
@mouseleave="hoveredMatch = null" :class="getRowClass(match.date)">
<td>{{ formatDate(match.date) }}</td>
<td>{{ match.time.toString().slice(0, 5) }} Uhr</td>
<td v-html="highlightClubName(match.homeTeam.name)"></td>
<td v-html="highlightClubName(match.guestTeam.name)"></td>
<td>{{ match.time ? match.time.toString().slice(0, 5) + ' Uhr' : 'N/A' }}</td>
<td v-html="highlightClubName(match.homeTeam?.name || 'N/A')"></td>
<td v-html="highlightClubName(match.guestTeam?.name || 'N/A')"></td>
<td v-if="selectedLeague === 'Gesamtspielplan' || selectedLeague === 'Spielplan Erwachsene'">{{ match.leagueDetails?.name || 'N/A' }}</td>
</tr>
</tbody>
</table>
@@ -107,11 +112,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');
}
@@ -126,8 +190,37 @@ export default {
this.matches = [];
}
},
async loadAllMatches() {
this.selectedLeague = 'Gesamtspielplan';
try {
const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches`);
this.matches = response.data;
} catch (error) {
alert('Fehler beim Laden des Gesamtspielplans');
this.matches = [];
}
},
async loadAdultMatches() {
this.selectedLeague = 'Spielplan Erwachsene';
try {
const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches`);
// Filtere nur Erwachsenenligen (keine Jugendligen)
const allMatches = response.data;
this.matches = allMatches.filter(match => {
const leagueName = match.leagueDetails?.name || '';
// Prüfe, ob es eine Jugendliga ist (J, M, Jugend im Namen)
const isYouth = /[JM]\d|jugend/i.test(leagueName);
return !isYouth;
});
} catch (error) {
alert('Fehler beim Laden des Erwachsenenspielplans');
this.matches = [];
}
},
formatDate(date) {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const wd = weekdays[d.getDay()];
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
@@ -135,6 +228,7 @@ export default {
return `${wd} ${day}`;
},
highlightClubName(teamName) {
if (!teamName) return 'N/A';
const clubName = this.currentClubName;
if (clubName && teamName.includes(clubName)) {
return `<strong>${teamName}</strong>`;
@@ -169,12 +263,13 @@ export default {
getUniqueLocations() {
const uniqueLocations = new Map();
this.matches.forEach(match => {
if (!match.location || !match.homeTeam) return;
const location = match.location;
const clubName = match.homeTeam.name;
const addressLines = [
location.name,
location.address,
`${location.zip} ${location.city}`
location.name || 'N/A',
location.address || 'N/A',
`${location.zip || ''} ${location.city || ''}`.trim()
];
const addressKey = addressLines.join('; ');
if (!uniqueLocations.has(addressKey)) {
@@ -184,6 +279,28 @@ export default {
return uniqueLocations;
},
getRowClass(matchDate) {
if (!matchDate) return '';
const today = new Date();
const match = new Date(matchDate);
// Setze die Zeit auf Mitternacht für genaue Datumsvergleiche
today.setHours(0, 0, 0, 0);
match.setHours(0, 0, 0, 0);
// Berechne die Differenz in Tagen
const diffTime = match.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'match-today'; // Heute - gelb
} else if (diffDays > 0 && diffDays <= 7) {
return 'match-next-week'; // Nächste Woche - hellblau
}
return ''; // Keine besondere Farbe
},
},
async created() {
await this.loadLeagues();
@@ -296,4 +413,42 @@ li {
color: #45a049;
cursor: pointer;
}
.special-link {
font-weight: bold;
color: #2c5aa0 !important;
padding: 5px 0;
border-bottom: 1px solid #ddd;
margin-bottom: 5px;
}
.special-link:hover {
background-color: #f0f8ff;
padding-left: 5px;
transition: all 0.3s ease;
}
.divider {
height: 1px;
background-color: #ddd;
margin: 10px 0;
cursor: default !important;
color: transparent !important;
}
.match-today {
background-color: #fff3cd !important; /* Gelb für heute */
}
.match-next-week {
background-color: #d1ecf1 !important; /* Hellblau für nächste Woche */
}
.match-today:hover {
background-color: #ffeaa7 !important; /* Dunkleres Gelb beim Hover */
}
.match-next-week:hover {
background-color: #b8daff !important; /* Dunkleres Blau beim Hover */
}
</style>

View File

@@ -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 SatzNummer
@@ -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>

View File

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